Skip to content

Multiple Currency in Membership Plans

Where this file lives

  • This path (docs/membership/multiple-currency-in-membership-plans.md) is inside the rakletv3 repo so it shows up in Cursor's file explorer.
  • Treat the repo file as canonical. Update it whenever the rules below change.

Feature: Per-plan Currency and Payment Account for Membership Plans

Branch: Multiple-currency-in-Membership-plans Repo: rakletadmin/rakletv3 Status: In review — pending merge with master (defer-membership-fee Mumadoo rule reconciled). Mode: Builder (multi-tenant payments feature).

Problem Statement

Until this branch, every Raklet membership plan inside an organisation was forced to use the organisation-level currency and the organisation default Payment Account. The application form, the apply/signup flow, the checkout, the renewal job and the UI all dereferenced organisation.Currency and organisation.DefaultPaymentAccountId to figure out "how much, in what currency, into which Stripe account".

This blocks two real customer needs:

  1. Multiple currencies in one organisation. A US chapter charges members in USD, the same legal entity also runs a TRY chapter on the same Raklet org. Today the chapter that is not the org default ends up labelled with the wrong currency on receipts, debt rows and emails.
  2. Multiple Stripe sub-accounts in one organisation. With the StripeWithDirectCharge processor an org can fan out to multiple connected sub-merchants — but only one PaymentAccountId per plan can be honoured at charge time, and the recurring/scheduled jobs must charge the same connected account that the original sale used.

What "multi-currency" means in this codebase

Two entirely different problems live under the same branch name. Treat them as two coupled invariants:

Problem Field Authority
Per-plan Currency CustomMemberType.Currency The plan, not the org
Per-plan Payment Account CustomMemberType.PaymentAccountId (+ CalculatedPaymentAccountId) The plan, falling back to org default

They are coupled because Stripe sub-accounts are currency-locked: a Stripe Connect account that processes USD cannot suddenly clear a TRY charge. So a per-plan currency without a per-plan payment account would be a half-solution and a per-plan payment account without a per-plan currency would be a footgun. Both ship together.

Constraints

  • Stack: .NET Framework 4.x web API + AngularJS manager UI + classic ASP.NET MVC views.
  • Processor gating: Per-plan PaymentAccountId is only meaningful for organisations whose CreditCardProcessor == StripeWithDirectCharge. For other processors (Iyzico, PayPal, plain Stripe), per-plan PA is rejected at write-time, hidden in the UI, and ignored at read-time (treated as "use the org default").
  • Append-only group plans: Once a parent plan with GroupMembershipEnabled has any subscription, sub-plans can be added but not removed — existing subscriptions may depend on them.
  • Immutability after creation: Currency and PaymentAccountId are frozen once a plan is created. Past members and outstanding debts assume the original values; mutating them mid-flight would corrupt history.
  • Hard-fail over silent fallback: Where a selected membership plan exists, the new code throws InvalidOperationException instead of silently falling back to organisation.Currency. A multi-currency org getting the wrong plan currency is worse than a 500. Legacy application forms with no selected plan still fall back to organisation.Currency because there is no plan currency to resolve.
  • Mumadoo "defer membership fee" temporary rule (master, expires 2026-06-01 UTC): three specific org GUIDs skip payment creation entirely on application approval. This rule is orthogonal to multi-currency — when payment creation runs, it must use per-plan currency/PA; when payment creation is skipped, the per-plan fields are simply not exercised.

Premises (locked)

  1. Stripe processor gateSupportsMultipleCurrencyPlanPA(organisation) returns true only for StripeWithDirectCharge. Every per-plan PA write goes through this gate. Plans created before the gate (legacy plans on other processors with a stray PaymentAccountId) are not migrated — they are simply allowed to exist and update without mutating their hidden field.
  2. CalculatedPaymentAccountId is the only consumer-facing read. Code that needs to know "where does this plan charge?" must read customMemberType.CalculatedPaymentAccountId, never customMemberType.PaymentAccountId directly. The computed property handles the null/empty/Guid.Empty fallback to org default in one place.
  3. Currency authority chain — for any payment-bearing artefact (Payment, Debt, PaymentTicket, CreditCardChargeRequest):
    invoice.Currency  →  plan.Currency  →  (hard-fail for plan-backed artefacts)
    
    organisation.Currency is still legitimately read for the CC charge request commission/fee currency which is org-wide (organisation.CommissionFeeCurrency), the PlanDto.Currency null fallback at create time (a brand-new plan with no client-supplied currency falls back to org default — but only at creation), and legacy application approval with no selected plan.
  4. Multi-plan single transaction guard. When the apply/signup flow lets a member buy multiple plans in one transaction, all selected plans must share the same CalculatedPaymentAccountId AND the same Currency — or the request is rejected with InvalidOperationException before any DB write. (Exception: legacy non-Stripe orgs are still allowed to mix because per-plan PA is moot for them.)
  5. Credit card token isolation. A saved card token is bound to exactly one PaymentAccountId. Selecting a token whose PA does not match the resolved invoice PA is rejected via DoesTokenMatchPaymentAccount(...) before any charge attempt. Recurring/scheduled flows re-derive the PA from the membership plan and re-validate the token on each iteration.
  6. Defer-membership-fee Mumadoo rule — preserved as-is; multi-currency code does not run when the defer rule short-circuits payment creation. After 2026-06-01 the defer rule will be deleted; multi-currency stays.

Business Rules (the contract)

These are the rules the implementation must keep true. Treat this as the spec — when in doubt, code matches this list, not the other way around.

R1 — Plan create (V2 POST /v2/organisations/{orgId}/membership/plans)

# Rule Where
R1.1 If Currency is null on the DTO, fall back to organisation.Currency. Currency is stored on the plan and not re-derived later. V2MembershipController.cs:919-921
R1.2 If PaymentAccountId is set but org processor is not StripeWithDirectCharge, reject with "PaymentAccountId is only supported for organisations using StripeWithDirectCharge." V2MembershipController.cs:910-915
R1.3 If PaymentAccountId is null or empty on the DTO, write organisation.DefaultPaymentAccountId into the entity so the plan is locked to a specific PA at creation. This keeps saved CreditCardToken.PaymentAccountId aligned if the org default is later switched. If the org also has no default, leave the field null and let CalculatedPaymentAccountId resolve it at read-time. V2MembershipController.cs:927-932, V2MembershipController.cs:961
R1.4 When GroupMembershipEnabled and sub-plans are linked, every sub-plan must (a) match the parent's PaymentAccountId via HasMatchingGroupMembershipPlanPA and (b) match the parent's Currency. Otherwise reject the create. V2MembershipController.cs:962-975

R2 — Plan update (V2 PUT /v2/organisations/{orgId}/membership/plans)

# Rule Where
R2.1 PA Stripe gate: if a non-empty PaymentAccountId differs from the existing one and org is not StripeWithDirectCharge, reject with "PaymentAccountId is only supported for organisations using StripeWithDirectCharge." This guard runs first, before R2.2. V2MembershipController.cs:1165-1170
R2.2 PA immutability: if a non-empty PaymentAccountId differs from the existing one, reject with "PaymentAccountId cannot be changed after plan creation." Empty/null payloads are tolerated (legacy plans on non-Stripe processors hide the field). V2MembershipController.cs:1172-1176
R2.7 Backfill legacy null PA on every update: before the immutability guards run, if the stored PaymentAccountId is null/empty and organisation.DefaultPaymentAccountId is set, write it onto the entity. This is a backfill (not a change) for plans created before R1.3 wrote the default. The immutability guard only triggers on an explicit DTO PA that differs from the (possibly just-backfilled) stored value. V2MembershipController.cs:1167-1174
R2.3 Currency immutability: if Currency.HasValue and the value differs from the existing plan's currency, reject with "Currency cannot be changed after plan creation." Null payloads are tolerated. V2MembershipController.cs:1178-1181
R2.4 Group plan append-only when subscribed: if the plan already has subscriptions (membershipCount > 0), GroupMembershipEnabled and GroupMembershipPriceType are locked, previously-linked sub plans cannot be removed, but new sub plans can still be added. V2MembershipController.cs:1202-1220
R2.5 Group plan free reset when not subscribed: if membershipCount == 0, the entire GroupMembershipPlans collection is rebuilt from the DTO. V2MembershipController.cs:1184-1201
R2.6 The 3 fail-fast guards (R2.1, R2.2, R2.3) run before any DB writes — including before the group-membership reconciliation in R2.4/R2.5. A rejected request must leave the plan untouched. V2MembershipController.cs:1163-1182

R3 — CalculatedPaymentAccountId computed property

# Rule Where
R3.1 Returns PaymentAccountId if set (non-null, non-Guid.Empty). CustomMemberType.cs:25-26
R3.2 Otherwise returns Organisation.DefaultPaymentAccountId. same
R3.3 If Organisation is not loaded (lazy-load null), returns Guid.Empty — never throws. Caller must guard against Guid.Empty in code paths that require a valid PA. same
R3.4 All CustomMemberTypeService.FindAsync / GetList queries must Include(x => x.Organisation) so this property resolves correctly. CustomMemberTypeService.cs

R4 — Application form: ApproveApplication (multi-plan single transaction)

# Rule Where
R4.1 Build a distinctPaymentAccountIds list from all selected plans' CalculatedPaymentAccountId. If count > 1 and org is not StripeWithDirectCharge, throw InvalidOperationException("The selected plans use different payment gateways and cannot be purchased at the same time. Please purchase them separately."). If count > 1 and org is StripeWithDirectCharge, throw InvalidOperationException("The selected plans belong to different payment accounts and cannot be processed in a single transaction. Please purchase them separately."). ApplicationFormService.cs:392-410
R4.2 After the PA check, call ResolveSingleApplicationCurrency(customMemberTypes, organisation.Currency, organisationId) which returns organisation.Currency when no plans are selected, returns the single shared plan currency when plans agree, or throws if (a) any selected plan is null or (b) selected plan currencies are mixed. ApplicationFormService.cs:416, ApplicationFormService.cs:1424-1445
R4.3 When creating the Payment row for an approved application, set Currency = customMemberType.Currency and PaymentAccountId = customMemberType.CalculatedPaymentAccountId. Never organisation.Currency, never organisation.DefaultPaymentAccountId directly. ApplicationFormService.cs:947-970
R4.4 Same fields are set on sub-member payments inside the corporate-membership flow: Currency = plan.Currency, PaymentAccountId = plan.CalculatedPaymentAccountId. The !isDeferMembershipFeeOrg guard sits before the field assignment so defer orgs don't even reach this code. ApplicationFormService.cs:706-738
R4.5 The CreditCardChargeRequest.Currency uses resolvedCurrency.ToString(). For selected plans this is the single resolved per-plan currency; for legacy no-plan applications it is organisation.Currency. ApplicationFormService.cs:610
R4.6 After charge success, the loaded creditCardToken is updated with the resolved PA: creditCardToken.PaymentAccountId = creditCardToken.PaymentAccountId.IsValidGuid() ? creditCardToken.PaymentAccountId : resolvedPaymentAccountId ?? organisation.DefaultPaymentAccountId. This prefers the selected-plan PA and falls back to the org default only when no resolved plan PA exists. ApplicationFormService.cs:989-1002
R4.7 Recurring scheduling enters the foreach loop only when (IsPaymentRecurring && creditCardToken != null) || isDeferMembershipFeeOrg. The HEAD-side creditCardToken != null guard is preserved to avoid scheduling without a saved token; the master-side || isDeferMembershipFeeOrg is preserved so the Mumadoo defer cleanup runs. ApplicationFormService.cs:1006

R5 — Multi-plan checkout / signup

# Rule Where
R5.1 Signup/Apply controllers must resolve the plan currency before token creation. Tokens are stamped with the resolved PaymentAccountId and bound to that account from then on. ApplyController.cs, SignupController.cs
R5.2 Saved-card selection in PaymentController rejects any token whose PaymentAccountId does not match the resolved invoice PA via DoesTokenMatchPaymentAccount(token, paymentAccountId). The check uses null/Guid.Empty as a wildcard "no preference" — actual mismatches are rejected. PaymentController.cs:669, PaymentController.cs:836, PaymentController.cs:843, PaymentController.cs:2639-2642

R6 — Scheduled membership payment lifecycle (V2 + WebJobs)

# Rule Where
R6.1 Create scheduled payment: before persisting, call IsScheduledPaymentTokenValidAsync(creditCardTokenId, organisationMembershipId, paymentAccountId). The check requires (a) the token exists, (b) it belongs to the same organisationMembershipId, (c) its PaymentAccountId matches the resolved PA (or the resolved PA is null/empty, treated as "no constraint"). V2PaymentController.cs:331-336, V2PaymentController.cs:544-560
R6.2 Update scheduled payment: if the ticket points at a CustomMemberType, re-derive resolvedPaymentAccountId from scheduledMembershipPlan.CalculatedPaymentAccountId (not from the DTO) and revalidate the token before saving. The DTO's PA is ignored for membership-bound tickets. V2PaymentController.cs:499-516
R6.3 The recurring WebJob (ScheduledPaymentFunctions) re-derives the PA from the membership plan on each iteration; mismatches between token PA and plan PA cause the iteration to fail loudly rather than silently re-route to a different sub-merchant. Raklet.WebJobs.Secondary/Payment/ScheduledPaymentFunctions.cs
R6.4 Manual invoice/debt create + update: POST /payment/debt and POST /payment/debt/{debtId} resolve currency server-side. If CustomMembershipId is present, use that subscription's CustomMemberType.Currency and derive CustomMemberTypeId from the subscription. If no subscription is selected, use organisation.Currency. Never trust an omitted DTO enum value, because Currency.TRY = 0 is the C# default. The same DebtCurrencyResolver is applied to the staff-only Admin/Organisations/{orgId}/debts endpoints, which have no subscription context and therefore always fall through to organisation.Currency. V2PaymentController.cs, AdminDebtsController.cs, DebtCurrencyResolver.cs
R6.5 Payment account deletion: reject deletion while an active (IsDeleted == false) plan references the account, and include that plan's name in the error. If no active plan references it, reject deletion while any PaymentTicket for the account is still Scheduled. Deleted plans and non-scheduled tickets do not block deletion. PaymentAccountService.cs, V2OrganisationController.cs

R7 — Frontend (AngularJS plan-edit)

# Rule Where
R7.1 isMultipleCurrencyPlanPAEnabled() returns true only when the org's processor is StripeWithDirectCharge (enum value 5 / '5' / 'StripeWithDirectCharge'). membership-plan-edit.controller.js:67-70
R7.2 hasMatchingGroupMembershipPlanPA(plan) enforces currency parity unconditionally (regardless of processor) and PaymentAccount parity only when R7.1 is true. PA comparison is case-insensitive. membership-plan-edit.controller.js:72-86
R7.3 A $watch on Data.Plan.PaymentAccountId and a second $watch on Data.Plan.Currency clear any sub-plan checkbox whose PA or Currency stops matching after the parent plan is edited. membership-plan-edit.controller.js:88-113
R7.4 getFilteredPlans() (the source for the sub-plan picker) filters by interval and by hasMatchingGroupMembershipPlanPA. Plans with mismatched currency or PA never appear in the picker. membership-plan-edit.controller.js:589-610
R7.5 In edit mode the Currency dropdown is ng-disabled with an immutability warning; the PaymentAccount selector is also disabled. Both fields show their effective values for context but cannot be changed. For legacy plans whose stored PaymentAccountId is null, the UI resolves the displayed account through OrganizationService.Organization.DefaultPaymentAccountId, which must be present in the /v2/me bootstrap payload. If the organisation has only one payment account, the UI still renders the field as a disabled <select> so the layout stays aligned with the multi-account edit view. plan-edit.html, membership-plan-edit.controller.js, V2MeController.cs
R7.6 In insert mode, currency defaults to OrganizationService.Organization.Currency once both currency list and Plan model are loaded. membership-plan-edit.controller.js:231-235

R8 — Read-side / display

# Rule Where
R8.1 Membership listing/detail views (Create.cshtml, MemberTypeEdit.cshtml, ChoosePlan.cshtml, Index.cshtml, SubscriptionCreate.cshtml, SubscriptionDetail.cshtml) must format prices with item.Currency or plan.Currency, never SessionService.Get.OrganisationCurrency. commit bc200b9ec
R8.2 JsonController plan select-lists format prices with plan.Currency, not session org currency. Application/Areas/Manager/Controllers/JsonController.cs
R8.3 PlanDto.Currency is Currency? (nullable). TRY (=0) is no longer indistinguishable from "not set". PlanDto.MapFromEntity reads from entity.Currency, not entity.Organisation.Currency. PlanDto.cs:28
R8.4 Manager manual-invoice subscription dropdowns expose each subscription's plan Currency and format the plan amount with CustomMemberType.Currency; the Add Invoice modal defaults to organisation currency only until a subscription is selected. V2ContactsController.cs, modal-update-debt.controller.js

R9 — Mumadoo defer-membership-fee rule (orthogonal, expires 2026-06-01 UTC)

# Rule Where
R9.1 isDeferMembershipFeeOrg = deferMembershipFeeOrgIds.Contains(organisationId) && DateTime.UtcNow < new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc). The set of three GUIDs is a hard-coded HashSet. ApplicationFormService.cs:417-423
R9.2 When defer is active: skip plan-fee accumulation (R4 still computes the rest), skip Payment row creation, scheduled-membership cleanup runs (set subscription Status=Scheduled, StartDate=RenewalDate=2026-06-01, remove erroneously created membership-fee debts and child payments, re-index child contacts). ApplicationFormService.cs:1015-1075
R9.3 Multi-currency rules (R4.3, R4.4) only apply on the non-defer branch — when defer skips payment creation, there is no payment row to stamp with currency/PA. No conflict. merge resolution in PR description
R9.4 After 2026-06-01 the entire deferMembershipFeeOrgIds block, the isDeferMembershipFeeOrg flag, and every if (isDeferMembershipFeeOrg) branch must be deleted. The multi-currency code does not depend on it. TODO comment in source

Architecture

                            ┌──────────────────────────────────────┐
                            │  CustomMemberType (the plan)         │
                            │  - Currency  (per plan, immutable)   │
                            │  - PaymentAccountId  (per plan, opt) │
                            │  - CalculatedPaymentAccountId  ◄─────┼─── single read-side accessor
                            └──────────────┬───────────────────────┘
                                           │
        ┌──────────────────────────────────┼──────────────────────────────────────┐
        │                                  │                                      │
        ▼                                  ▼                                      ▼
┌──────────────────┐         ┌─────────────────────────┐         ┌────────────────────────────┐
│ Plan create/edit │         │  Apply / Signup flow    │         │ Scheduled payments / jobs  │
│  (R1, R2, R7)    │         │  (R4, R5)               │         │  (R6)                      │
│                  │         │                         │         │                            │
│ V2MembershipCtrl │         │ ApplicationFormService  │         │ V2PaymentController        │
│ AngularJS edit   │         │  - PA conflict check    │         │  - IsScheduledPayment-     │
│  (Stripe gate,   │         │  - ResolveSingleApp-    │         │    TokenValidAsync         │
│  immutability,   │         │    Currency             │         │  - re-derive PA from plan  │
│  group parity)   │         │  - Payment.Currency =   │         │                            │
│                  │         │    customMemberType.    │         │ ScheduledPaymentFunctions  │
│                  │         │    Currency             │         │  (WebJob, same rule)       │
└──────────────────┘         │  - Payment.Payment-     │         └────────────────────────────┘
                             │    AccountId =          │
                             │    customMemberType.    │
                             │    CalculatedPayment-   │
                             │    AccountId            │
                             └──────────┬──────────────┘
                                        │
                                        ▼
                             ┌──────────────────────────┐
                             │ CreditCardChargeRequest  │
                             │  Currency = resolved      │
                             │   plan currency           │
                             │  ↓                        │
                             │ Charge success            │
                             │  ↓                        │
                             │ CreditCardToken           │
                             │  PaymentAccountId =       │
                             │   resolved plan PA        │
                             └──────────────────────────┘

Key invariants (cheat sheet)

  • Currency authority: plan, never org (except CC commission/fee currency).
  • PA authority: plan via CalculatedPaymentAccountId, falling back to org default — but only the computed property is allowed to fall back.
  • Stripe direct-charge gate: every per-plan PA write goes through SupportsMultipleCurrencyPlanPA.
  • Immutability: Currency and PA are frozen after plan creation. Group plans are append-only after subscriptions exist.
  • Multi-plan single transaction: all selected plans must agree on PA AND currency, or hard-fail before DB writes.
  • Token isolation: a saved card token is bound to one PA; mismatches reject the charge.
  • Scheduled re-derivation: scheduled membership payments re-derive PA from the plan on each iteration, not from the stored ticket field.
  • Defer rule: Mumadoo skip-payment branch is orthogonal to multi-currency; both ship and the merge keeps both.

Single coherent change set across: - Models: CustomMemberType.Currency already existed; added [NotMapped] CalculatedPaymentAccountId as a single read-side resolver. PlanDto.Currency made nullable so TRY (=0) is not ambiguous. - V2 API: V2MembershipController.CreatePlan/UpdatePlan enforce R1+R2 with three guards before any DB write. V2PaymentController (Create/UpdateScheduledPayment) enforces R6 via IsScheduledPaymentTokenValidAsync. - Services: ApplicationFormService enforces R4 with ResolveSingleApplicationCurrency and the distinctPaymentAccountIds check. CustomMemberTypeService adds Include(x => x.Organisation) to all reads so R3 resolves correctly. PaymentController adds DoesTokenMatchPaymentAccount (R5). PaymentAccountService prevents deleting accounts that are still referenced by active plans or scheduled payments (R6.5). - Frontend: AngularJS plan-edit controller adds isMultipleCurrencyPlanPAEnabled, hasMatchingGroupMembershipPlanPA, the two $watch clearers, and disables Currency/PA in edit mode (R7). Six MVC views switch from SessionService.Get.OrganisationCurrency to item.Currency / plan.Currency (R8).

Out of scope for this branch

  • FX conversion between currencies. There is no "one mixed total in USD". A multi-currency org sees one number per currency; receipts and emails show whichever currency was on the underlying invoice.
  • Currency migration of legacy plans. Plans that existed before this branch keep their currency (which equals their org's currency at creation time). No backfill, no remap.
  • Multi-PA on non-Stripe processors. Iyzico/PayPal/plain Stripe orgs cannot use per-plan PA. The UI hides the field, the API rejects writes.
  • Per-event currency. Events keep their own EventDoc.Currency rules; this branch does not touch the events module.
  • Donation currency. Donations have a separate currency model (donation campaign currency); the propagation commits in this branch (d108628c0, f629d8c0c, 7586a8202) extend the same per-invoice currency principle but the rules are governed by the donation feature, not this doc.

Test plan

See companion file: multiple-currency-in-membership-plans-test-plan.md.

Headline coverage:

  • Unit: CustomMemberTypeTests covers R3 (PA fallback / Org-null safety / immutability vs. computed property). CheckoutServiceTests covers subset-sum matching for multi-PA invoice/payment selection. PaymentAccountsDtoTests covers IsSinglePaymentAccount and DTO mapping for multi-PA mode. All three test classes are DB-independent.
  • Integration: V2 plan create/update with all three guard combinations (Stripe gate, PA immutability, Currency immutability) on a real RakletDb test fixture. PaymentAccountDeletionIntegrationTests executes the deletion reference queries against SQL Server for active/deleted plans and scheduled/non-scheduled tickets.
  • Manual / E2E: Multi-plan apply form on a StripeWithDirectCharge org with two plans on different sub-merchants → 400 with the conflict message. Same form with two plans on the same sub-merchant but different currencies → 400 with the currency message. Same form with two plans agreeing on both → success, charge hits the correct sub-merchant, recurring schedule re-derives PA from plan on next iteration.

Open questions / known follow-ups

  1. Legacy plans on non-Stripe orgs with stray PaymentAccountId. R1.2 / R2.1 reject writes that would change the field, but reads still flow through CalculatedPaymentAccountId which honours the stored value. Decision needed: do we want to actively zero out PaymentAccountId on these plans, or keep "respect what's in the DB"?
  2. Donation-side currency is propagated by the same series of commits but not enforced by this doc. If donation-side regressions show up, they should be filed under a separate doc, not retro-fitted here.
  3. Mumadoo cleanup task — schedule a deletion PR for the deferMembershipFeeOrgIds block on or after 2026-06-01. After deletion, R9 in this doc can be removed entirely.

Commit history (build order)

Branch: Multiple-currency-in-Membership-plans. Most recent first; merge commits omitted from this list.

Commit What it adds
33b4ace73 Validate membership payment-account binding in V2 schedules (R6)
a614c6a34 Resolve PA mismatch on credit card token transactions (R5)
bc200b9ec UI views switch from session org currency to per-plan currency (R8)
d108628c0 Per-plan/ticket currency in donation, event email, import flows
f629d8c0c Per-ticket currency in webhook, installment, saved-card flows
7586a8202 Per-invoice currency through checkout and payment flows
4f9f942d8 Per-plan currency in apply/signup flows
f0eae44a2 Per-plan Currency on plan create/edit (R1, R2, R7, R8.3)
91d2b68a9 Enforce PaymentAccountId consistency and immutability (R1.4, R2.1, R2.2)
6859e199d PaymentAccountId immutable after plan creation (R2.2)
a3798b5fa Restrict plan PA to Stripe direct charge (R1.2, R2.1, R7.1)
4c4756944 CalculatedPaymentAccountId computed property (R3)
c311d0fc0 Use CalculatedPaymentAccountId for multi-plan PA conflict validation (R4.1)
6f5e8388d Unit tests for multi-currency membership plans
c66b756da Add PaymentAccountId to membership plans (data model entry point)

What I noticed about how this feature was built

  • The team gated per-plan PA on a single processor (StripeWithDirectCharge) instead of trying to make every gateway support multi-account. That's the right call — Iyzico and plain Stripe don't have a clean direct-charge story for connected sub-merchants.
  • The CalculatedPaymentAccountId computed property is the single chokepoint that lets every read site stop caring about "is the field set or do I fall back to org default?". Adding it once means none of the consumer sites have to repeat the logic.
  • Hard-failing instead of silently falling back to organisation.Currency is the right call for a multi-tenant payments feature — a wrong currency on a charge is worse than a 500 the engineer can fix.
  • The merge with master's Mumadoo defer rule is a textbook "two orthogonal additions on adjacent lines" conflict. The resolution preserves both because they touch entirely different code paths (defer skips payment creation; multi-currency stamps payments that are created).

Follow-up: legacy Debt currency reconciliation

R6.4 fixes the write path — new and updated manual debts will now persist a currency consistent with the attached subscription plan. It does not backfill historical rows. Before enabling multi-currency plans on an organisation that has a meaningful manual-debt history, run the following audit to surface existing mismatches:

SELECT TOP (100) d.Id, d.OrganisationId, d.Currency AS DebtCurrency, cmt.Currency AS PlanCurrency
FROM   Debts d
JOIN   CustomMemberTypes cmt ON cmt.Id = d.CustomMemberTypeId
WHERE  d.Currency <> cmt.Currency;

Treat any rows returned as data to reconcile with the organisation — a straight UPDATE d SET Currency = cmt.Currency may silently change the amount a member appears to owe in an unrelated currency. Backfill policy is per-organisation and out of scope for the R6.4 code change.