Repository navigation
feat(subscription): optional cancellation reason and single-call charge settlement - #161
Open
sommydregent wants to merge 1 commit into
Open
sommydregent wants to merge 1 commit into
sommydregent wants to merge 1 commit into
Conversation
Malware and injection scan0 blocking, 1 to review, 0 scanner error(s), 11 changed file(s). Needs maintainer review
Scanned commit 0b2b05a. Full run and report This pull request will not be merged automatically. A maintainer will review the findings; if they are false positives, they can merge it by hand. Pushing a new commit runs the scan again. |
…ement Subscribers could only cancel anonymously, so a merchant had no way to learn why a mandate ended from chain data. `cancel_with_reason` performs exactly the same cancellation as `cancel` and adds the reason to the `Cancelled` event; the codes are a closed, documented set (`CANCEL_REASON_*`) and an out-of-range code is rejected with `InvalidCancelReason` before storage is touched. `cancel` and `cancel_page` report reason 0, so the event shape is constant. `charge` paid the merchant and the protocol fee with two `debit` calls, repeating the vault's authorization, `Subscription` read and balance read/write for the same total. `vault.debit_split` does that work once and then makes the same two token transfers, so balances and the per-payee `Debit` events are unchanged, including the running balances; a zero fee leaves the second leg empty. `charge` and `upgrade` use it, and a tipped charge makes two vault calls instead of three. The split total is added with `checked_add` so it can never wrap, reported as the new `AmountOverflow`. Both new error variants are appended to their enums so existing codes keep their numbers. Closes MMM-pay#73 Closes MMM-pay#74
sommydregent
force-pushed
the
issue-73-74-cancel-reason-and-split-debit
branch
from
October 26, 2026 11:25
825b0be to
0b2b05a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two related changes: how a mandate ends (#73) and how a charge settles (#74), both built on current
main.#73 — optional cancellation reason
cancelgave a merchant no signal about why a subscriber left. The newcancel_with_reason(subscriber, mandate_id, reason)performs exactly the same cancellation ascancel— same auth, same state change, same events — and adds areasoncode toCancelled, so churn is visible from chain data alone.CANCEL_REASON_NONEcancel/cancel_pageemit)CANCEL_REASON_TOO_EXPENSIVECANCEL_REASON_NOT_USINGCANCEL_REASON_MISSING_FEATURECANCEL_REASON_SERVICE_QUALITYCANCEL_REASON_OTHERThe code set is closed:
reason > MAX_CANCEL_REASONreturns the newInvalidCancelReasonbefore any storage is touched, so a rejected call cannot alter a mandate and an indexer never has to handle an unknown code. The codes are exported as public constants.cancelis now that function called withCANCEL_REASON_NONE,cancel_pagereports0per mandate it clears, andend_mandateis untouched (a merchant ending a mandate is not a subscriber cancellation).#74 — one vault call per charge
chargecalledvault.debitonce per payee (merchant share, then fee, then the optional keeper tip), repeating the vault's subscription auth check,Subscriptionread, balance read and balance write for the same total.vault.debit_split(user, token, to_a, amount_a, to_b, amount_b)does that work once and then makes the same two token transfers. It emits the same oneDebitper non-empty leg with the same running balances the sequential debits reported (first event: balance afteramount_a; second: after both legs), so indexers see byte-identical data.amount_amust be positive,amount_bmay be zero — a zero-fee settlement then has a single leg and emits a singleDebit, exactly like the old single-call path — and the total is added withchecked_add, because a wrap would under-charge the subscriber (newAmountOverflow).chargeandupgrade(which collects a prorated difference the same way) both settle merchant share + fee through onedebit_split. The keeper tip is a third payee and keeps its own debit, so a tipped charge makes two vault calls instead of three.Acceptance criteria
#73
cancel_with_reason, theCANCEL_REASON_*constants, theCancelled.reasonfield doc, andCHANGELOG.md.cancelemits 0 —plain_cancel_emits_reason_zeroreadsreasonoff the emitted event XDR (andcancel_page_reports_no_reasoncovers the bulk path).#74
charge_settles_merchant_and_fee_through_one_vault_call(twoDebitevents + merchant/fee/vault balances),a_zero_fee_charge_emits_one_debit(oneDebit),a_tipped_charge_still_pays_three_payees(threeDebits, tip still paid), plus the vault's owndebit_split_pays_both_recipients_from_one_balance_update, which asserts both event balances. All pre-existing fee/tip/refund/upgrade tests are unchanged and green.Measured resource reduction (#74)
Measured with the test host's cost estimator (
Env::cost_estimate().budget()), mainnet resource limits enforced, identical world and state in both runs (real Stellar Asset Contract, funded subscriber, 1% fee, no keeper tip,PRICE = 10_000), settling the second charge of an active mandate. The only difference between the two builds ischarge's payout block: twovault.debitcalls versus onevault.debit_split.chargepayout pathvault.debitvault.debit_splitCounts are deterministic on the test host. What is saved is one whole cross-contract invocation frame plus the duplicated auth check,
Subscriptionread and balance read/write; the two token transfers (one per payee) remain, so this is the floor for a two-recipient settlement. A tipped charge saves one frame out of three instead of one out of two.Tests
cargo test --all: 247 passed, 0 failed — 18 new:cancel(0) andcancel_with_reason; every documented code accepted;MAX_CANCEL_REASON + 1andu32::MAXrejected withInvalidCancelReasonand the mandate leftActive; stranger and merchant rejected withNotSubscriber; a wrong signer's auth rejected; double cancel rejected;cancel_pagereports 0; charge emits twoDebits; zero-fee charge emits one; tipped charge emits three and still pays all three payees.Debit; combined-total check (neither leg alone exceeds the balance, the sum does, nothing moves); zero/negative legs;i128overflow; and two negative auth tests — a specific wrong signer viaMockAuth/MockAuthInvoke, and no auth at all.Both new mutating entry points carry a negative auth test in the naming
scripts/check_auth_tests.shexpects (cancel_with_reason_wrong_signer_rejected,debit_split_requires_the_subscription_auth).The subscription crate pins event layouts:
test_events.rsgainsreasonforcancelledandtest_snapshots/events/cancelled.xdris regenerated, so the XDR change is explicit and reviewable rather than silent.Interface and event changes (downstream)
Cancelledgains a trailingreason: u32. Follow-up issues are needed in payflow-backend (event decoder), payflow-frontend (optional reason picker on cancel) and payflow-docs (events reference + error-code page). Noted underUnreleasedinCHANGELOG.md.AmountOverflow = 11, subscriptionInvalidCancelReason = 27.Subscription::cancel_with_reason,Vault::debit_split(also added tocontracts/subscription/src/interfaces.rs).bindings/(generated TypeScript clients) is derived from the contract specs; theCancelledtype there needs a regeneration follow-up.Checklist (all run locally on this commit)
cargo fmt --all -- --checkcargo clippy --all-targets -- -D warningscargo test --all— 247 passed./scripts/check_auth_tests.sh— PASSRUSTUP_TOOLCHAIN=1.91.0 cargo check --all --all-targetsstellar contract buildwith stellar-cli 27.0.0 — all three contracts build, andcancel_with_reason/debit_splitappear in the built specs (payflow_subscription.wasm52 489 B,payflow_vault.wasm15 368 B,payflow_plan_registry.wasm22 082 B)0a56decb…registry,55c1c352…subscription,54026786…vault)Closes #73
Closes #74