Test Plan — Multiple Currency in Membership Plans¶
Base: origin/master
Branch: Multiple-currency-in-Membership-plans
Related design: multiple-currency-in-membership-plans.md
Repo: rakletadmin/rakletv3
Headline rule: every per-plan currency / payment-account read must come from the plan, never the organisation, except where the design doc explicitly allows it (CC commission/fee currency, plan-create-time fallback).
Affected pages / routes¶
- V2 API
POST /v2/organisations/{orgId}/membership/plans— plan create (R1)PUT /v2/organisations/{orgId}/membership/plans— plan update (R2)POST /v2/organisations/{orgId}/payment/scheduled— schedule create (R6.1)PUT /v2/organisations/{orgId}/payment/scheduled— schedule update (R6.2)POST /v2/organisations/{orgId}/payment/debt— manual invoice create (R6.4)POST /v2/organisations/{orgId}/payment/debt/{debtId}— manual invoice update (R6.4)- MVC / Application
Application/Controllers/ApplyController— apply form (R5)Application/Controllers/SignupController— signup form (R5)Application/Controllers/PaymentController— saved-card selection (R5.2)ApplicationFormService.ApproveApplication— multi-plan approve (R4)- AngularJS manager UI
admin.raklet.com/ng#/manager/membership/plans— plan list, create, edit (R7)- MVC member-facing views
Create.cshtml,MemberTypeEdit.cshtml,ChoosePlan.cshtml,Index.cshtml,SubscriptionCreate.cshtml,SubscriptionDetail.cshtml(R8)- WebJobs
Raklet.WebJobs.Secondary/Payment/ScheduledPaymentFunctions— recurring iteration (R6.3)
Codepaths to cover¶
| # | Flow / branch | Location | Test type | Exists? | Gap / action |
|---|---|---|---|---|---|
| R1.1 | Plan create with Currency=null falls back to org currency |
V2MembershipController.cs:919-921 | Integration | Gap | Add: POST without Currency, assert entity persisted with org default |
| R1.2 | Plan create with PA on non-Stripe org → 400 | V2MembershipController.cs:910-915 | Integration | Gap | Add: POST with PaymentAccountId on Iyzico org, assert error message |
| R1.3 | Plan create with empty PA leaves field null | V2MembershipController.cs:951 | Integration | Gap | Add: POST with PaymentAccountId=Guid.Empty, assert DB row is null |
| R1.4 | Plan create with mismatched group sub-plan PA → 400 | V2MembershipController.cs:962-975 | Integration | Gap | Add: parent + sub-plan with different PA, assert reject |
| R1.4 | Plan create with mismatched group sub-plan currency → 400 | same | Integration | Gap | Add: parent + sub-plan with different Currency, assert reject |
| R2.1 | Plan update — PA Stripe gate runs first | V2MembershipController.cs:1165-1170 | Integration | Gap | Add: PUT changing PA on non-Stripe org, assert Stripe-gate message |
| R2.2 | Plan update — PA immutability after gate | V2MembershipController.cs:1172-1176 | Integration | Gap | Add: PUT changing PA on Stripe org, assert immutability message |
| R2.2 | Plan update — empty/null PA payload tolerated | same | Integration | Gap | Add: PUT with PaymentAccountId=null, assert no change, no error |
| R2.3 | Plan update — Currency immutability | V2MembershipController.cs:1178-1181 | Integration | Gap | Add: PUT changing Currency, assert reject + plan unchanged |
| R2.4 | Subscribed group plan — sub-plans append-only | V2MembershipController.cs:1202-1220 | Integration | Gap | Add: parent with subscriptions, attempt removing a sub-plan, assert preserved |
| R2.5 | Unsubscribed group plan — full rebuild | V2MembershipController.cs:1184-1201 | Integration | Gap | Add: parent with no subscriptions, assert sub-plan list rewritten |
| R2.6 | Rejected update leaves plan untouched | same | Integration | Gap | Add: PUT that fails R2.3, assert no DB write occurred |
| R3.1-3 | CalculatedPaymentAccountId priority + fallback + Org-null safety |
CustomMemberType.cs:25-26 | Unit | Exists | CustomMemberTypeTests covers these. Verify still passing. |
| R3.4 | All CustomMemberTypeService reads Include(Organisation) |
CustomMemberTypeService.cs | Static / code review | Gap | Grep audit; consider analyzer rule |
| R4.1 | Multi-plan approve — distinct PA on non-Stripe → reject | ApplicationFormService.cs:392-410 | Integration | Gap | Add: 2 plans, different PA, non-Stripe org, assert "different payment gateways" message |
| R4.1 | Multi-plan approve — distinct PA on Stripe → reject | same | Integration | Gap | Add: 2 plans, different PA, Stripe org, assert "different payment accounts" message |
| R4.2 | Multi-plan approve — no-plan fallback, mixed currency reject | ApplicationFormService.cs:1424-1445 | Unit | Exists | ResolveSingleApplicationCurrencyTests covers no-plan org-currency fallback, single-plan, agreeing, and mixed-currency paths. Keep green. |
| R4.3 | Approve writes Payment.Currency and PaymentAccountId from plan |
ApplicationFormService.cs:947-970 | Integration | Gap | Approve a plan whose currency ≠ org currency, assert Payment row stamped with plan currency |
| R4.4 | Sub-member payments use plan currency/PA | ApplicationFormService.cs:706-738 | Integration | Gap | Corporate membership with sub-members, assert each sub Payment uses plan PA |
| R4.5 | CreditCardChargeRequest.Currency = resolvedCurrency |
ApplicationFormService.cs:610 | Integration | Gap | Mock charger, assert charge request currency = plan currency |
| R4.6 | Token bound to PA after charge | ApplicationFormService.cs:989-1002 | Integration | Gap | Approve, assert CreditCardToken.PaymentAccountId set |
| R4.7 | Recurring loop guard (IsRecurring && token != null) || isDeferOrg |
ApplicationFormService.cs:1006 | Integration | Gap | Approve recurring without token → no schedule. Approve as defer org → cleanup runs. |
| R5.2 | DoesTokenMatchPaymentAccount rejects mismatched token |
PaymentController.cs:2639-2642 | Unit | Exists | DoesTokenMatchPaymentAccountTests covers mismatch, wildcard, null-token, and null/empty-token-PA cases. Keep green. |
| R6.1 | IsScheduledPaymentTokenValidAsync happy path |
V2PaymentController.cs:544-560 | Integration | Gap | Create scheduled payment with matching token; assert created |
| R6.1 | Token wrong org-membership → reject | same | Integration | Gap | Token belongs to other membership, assert reject |
| R6.1 | Token wrong PA → reject | same | Integration | Gap | Token PA ≠ resolved PA, assert reject |
| R6.2 | Update re-derives PA from plan, ignores DTO PA | V2PaymentController.cs:499-516 | Integration | Gap | PUT with DTO PA different from plan PA, assert plan PA wins |
| R6.3 | WebJob recurring iteration re-derives PA | ScheduledPaymentFunctions.cs | Integration | Gap | Run iteration with mismatched stored PA, assert hard-fail (loud, not silent reroute) |
| R6.4 | Manual invoice/debt create + update resolves currency from subscription plan or org fallback | V2PaymentController.cs, DebtCurrencyResolver.cs | Unit + Integration | Partial | DebtCurrencyResolutionTests covers pure selection. Add integration: POST without Currency, with USD plan on EUR org, assert Debt.Currency=USD; POST without subscription assert EUR, not TRY. |
| R7.1 | isMultipleCurrencyPlanPAEnabled only true for processor 5 |
membership-plan-edit.controller.js:67-70 | Karma / manual | Gap | Manual: switch processor in fixture, observe field visibility |
| R7.2 | hasMatchingGroupMembershipPlanPA parity rules |
membership-plan-edit.controller.js:72-86 | Karma / unit-js | Gap | Add JS unit covering Stripe vs non-Stripe parity |
| R7.3 | $watch clears mismatched sub-plans on edit |
membership-plan-edit.controller.js:88-113 | Manual / E2E | Gap | Manual: change parent currency, observe sub checkbox cleared |
| R7.4 | getFilteredPlans filters mismatched plans |
membership-plan-edit.controller.js:589-610 | Manual / E2E | Gap | Manual: parent in USD, sub-plan picker hides TRY plans |
| R7.5 | Edit mode: Currency + PA disabled | plan-edit.html | Manual / E2E | Gap | Manual: open existing plan, assert ng-disabled |
| R7.6 | Insert mode: Currency defaults to org currency | membership-plan-edit.controller.js:231-235 | Manual / E2E | Gap | Manual: new plan, observe default |
| R8.1 | Member-facing views format with item.Currency |
commit bc200b9ec |
Manual | Done in PR | Visual: open membership profile in TRY plan org with USD plan, assert correct symbol |
| R8.2 | JsonController plan select-list uses plan.Currency |
JsonController.cs | Manual | Gap | Open select list, inspect rendered prices |
| R8.3 | PlanDto.Currency nullable, TRY (=0) distinguishable from null |
PlanDto.cs:28 | Unit | Exists | Verify PlanDto.MapFromEntity test path |
| R9.1 | isDeferMembershipFeeOrg flag computation |
ApplicationFormService.cs:417-423 | Unit | Exists | DeferMembershipFeeOrgTests covers org-set membership and before/after/boundary time-window behavior. Keep green. |
| R9.2 | Defer-active path skips Payment, runs cleanup | ApplicationFormService.cs:1015-1075 | Integration | Gap | Approve with defer org, assert subscription Status=Scheduled, debt removed, no Payment row |
| R9.3 | Multi-currency code does not run on defer branch | merge resolution | Integration | Gap | Same defer test: assert Payment.Currency not exercised (no row created) |
| R9.4 | Cleanup TODO present | TODO comment in source | Static | n/a | Reviewer checklist only |
Key interactions to verify (manual / E2E)¶
- Multi-currency org: Plan A in USD on PA-A, Plan B in TRY on PA-B (
StripeWithDirectCharge). Member can buy each separately. Buying both in one apply form → reject with "different payment accounts". - Same PA, different currency: Plan A USD, Plan B TRY, both on PA-A. Apply form with both → reject with currency mismatch.
- Same PA, same currency: Plan A USD, Plan B USD, both on PA-A. Apply form with both → success, single charge, single Payment per plan, both stamped with USD + PA-A.
- Recurring re-derive: Successful recurring purchase. Wait for next iteration (or fire WebJob manually). Stored ticket PA changed to a wrong value out-of-band → next iteration fails loudly with token/PA mismatch, does NOT silently reroute to a different sub-merchant.
- Saved-card UX: Member with token on PA-A tries to pay invoice on PA-B → token hidden / rejected. Member with token on PA-A pays invoice on PA-A → token offered.
- Manual invoice UX: Add Invoice with a selected subscription whose plan currency differs from the org; amount prefix and saved
Debt.Currencymust use the plan currency. Add Invoice without a subscription must save org currency, not enum default TRY. - Plan edit immutability: Open existing plan in manager UI. Currency dropdown disabled with warning. PaymentAccount selector disabled. Save with unchanged values → success. Attempt API PUT bypassing UI to change Currency or PA → 400.
- Group plans: Parent USD/PA-A. Add USD/PA-A sub-plan → ok. Add TRY/PA-A sub-plan → blocked in UI + rejected by API. Add USD/PA-B sub-plan → blocked in UI + rejected by API. Subscribe a member, then try to remove a sub-plan → blocked.
- Mumadoo defer (until 2026-06-01): Approve application on one of the 3 defer-org GUIDs. Subscription created with
Status=Scheduled,StartDate=2026-06-01, no Payment row, no debt row. After mocking clock past2026-06-01→ defer branch is dead, normal multi-currency path runs. - Mumadoo defer ROLLBACK readiness: Confirm a single-PR delete of the
deferMembershipFeeOrgIdsblock compiles without touching multi-currency code paths.
Edge cases¶
- Org with no
DefaultPaymentAccountIdset —CalculatedPaymentAccountIdreturnsGuid.Empty. Caller code must guard. Test: create plan with no PA on org with no default → resolved toGuid.Empty, charge attempt fails fast with clear error (not NRE). - Plan with stale Currency stored as
0(TRY) — distinguishable from null after R8.3 nullable fix. Test legacy plan rows. - Lazy-load not triggered (Organisation null on the navigation property) —
CalculatedPaymentAccountIdreturnsGuid.Emptyrather than throw. Verify withAsNoTrackingquery that didn'tInclude(Organisation). - Token deleted between schedule create and charge time —
IsScheduledPaymentTokenValidAsyncshould still reject on subsequent operations. - Org switches processor after plans exist — legacy plans created on
StripeWithDirectChargekeep their PA; updates that touch other fields should not reject because of the now-failing Stripe gate. R2.1 only fires when PA value changes. - Currency immutability under round-trip — DTO returned by GET, posted back unchanged on PUT → R2.3 must accept (HasValue but equal).
- Group parent plan currency change attempt — should fail R2.3 before any group reconciliation; sub-plans must remain untouched.
Critical paths (must pass before merge)¶
- Plan create / update guards (R1, R2) — all six guard combinations on a real
RakletDbintegration fixture. CalculatedPaymentAccountIdunit suite green.- Multi-plan approve hard-fail on PA conflict + currency conflict.
- Recurring iteration re-derives PA and rejects mismatched token.
- Member-facing views render plan currency, not session org currency, on a multi-currency org.
- Mumadoo defer org approve still works end-to-end and does NOT touch the multi-currency assignment lines.
Automated tests¶
- Unit (DB-independent)
CustomMemberTypeTests— R3 — exists, keep green.ResolveSingleApplicationCurrencyTests— R4.2 — exists, keep green.DoesTokenMatchPaymentAccountTests— R5.2 — exists, keep green.DeferMembershipFeeOrgTests— R9.1 — exists, keep green.DebtCurrencyResolutionTests— R6.4 — exists, keep green.PlanDto.MapFromEntitynullable currency — R8.3 — verify covered.- Integration (
RakletDbfixture) - V2 plan create / update guard matrix — R1, R2.
- V2 scheduled payment create / update — R6.1, R6.2.
ApplicationFormService.ApproveApplicationhappy path + multi-plan rejects + sub-member assignment — R4.- Frontend
- JS unit / Karma for
hasMatchingGroupMembershipPlanPAif a test runner is wired; otherwise add to manual checklist. - WebJob
- Local invocation of
ScheduledPaymentFunctionsrecurring iteration with seeded data — R6.3.
Build / regression¶
- Run
scripts/dev/build-fast.ps1after changes inModels,Services,Raklet.Api,Application. UI-only AngularJS changes can be validated with browser refresh aftergulp(per repo convention). - Full solution build before opening the merge PR — non-SDK
.csprojregistration is required for any new test files (seeCLAUDE.md).
Sign-off¶
- [ ] R1 — plan create guards green (4 cases)
- [ ] R2 — plan update guards green (3 fail-fast + group append-only)
- [ ] R3 —
CalculatedPaymentAccountIdunit suite green - [ ] R4 — multi-plan approve PA + currency rejects, payment rows stamped with plan values
- [ ] R5 — saved-card token isolation enforced in checkout
- [ ] R6 — scheduled payment create/update + WebJob iteration validate token + re-derive PA from plan
- [ ] R7 — AngularJS plan-edit: gate, parity, watches, immutability
- [ ] R8 — member-facing views render plan currency, not session org currency
- [ ] R9 — Mumadoo defer-org approve still passes; multi-currency code path is not exercised on defer branch
- [ ] Full solution build clean, all new
.csfiles registered in their.csproj - [ ] Manual QA on a
StripeWithDirectChargeorg with two sub-merchants and two currencies