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:
- 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.
- 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
GroupMembershipEnabledhas 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
InvalidOperationExceptioninstead of silently falling back toorganisation.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 toorganisation.Currencybecause 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)¶
- Stripe processor gate —
SupportsMultipleCurrencyPlanPA(organisation)returnstrueonly forStripeWithDirectCharge. Every per-plan PA write goes through this gate. Plans created before the gate (legacy plans on other processors with a strayPaymentAccountId) are not migrated — they are simply allowed to exist and update without mutating their hidden field. CalculatedPaymentAccountIdis the only consumer-facing read. Code that needs to know "where does this plan charge?" must readcustomMemberType.CalculatedPaymentAccountId, nevercustomMemberType.PaymentAccountIddirectly. The computed property handles the null/empty/Guid.Empty fallback to org default in one place.- Currency authority chain — for any payment-bearing artefact (Payment, Debt, PaymentTicket, CreditCardChargeRequest):
invoice.Currency → plan.Currency → (hard-fail for plan-backed artefacts)organisation.Currencyis 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. - 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
CalculatedPaymentAccountIdAND the sameCurrency— or the request is rejected withInvalidOperationExceptionbefore any DB write. (Exception: legacy non-Stripe orgs are still allowed to mix because per-plan PA is moot for them.) - 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. - 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.
Recommended Approach (the one shipped)¶
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.Currencyrules; 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:
CustomMemberTypeTestscovers R3 (PA fallback / Org-null safety / immutability vs. computed property).CheckoutServiceTestscovers subset-sum matching for multi-PA invoice/payment selection.PaymentAccountsDtoTestscoversIsSinglePaymentAccountand 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
RakletDbtest fixture.PaymentAccountDeletionIntegrationTestsexecutes the deletion reference queries against SQL Server for active/deleted plans and scheduled/non-scheduled tickets. - Manual / E2E: Multi-plan apply form on a
StripeWithDirectChargeorg 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¶
- Legacy plans on non-Stripe orgs with stray
PaymentAccountId. R1.2 / R2.1 reject writes that would change the field, but reads still flow throughCalculatedPaymentAccountIdwhich honours the stored value. Decision needed: do we want to actively zero outPaymentAccountIdon these plans, or keep "respect what's in the DB"? - 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.
- Mumadoo cleanup task — schedule a deletion PR for the
deferMembershipFeeOrgIdsblock 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
CalculatedPaymentAccountIdcomputed 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.Currencyis 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.