Repository navigation
feat(subscription): optional cancellation reason and single-call charge settlement - #160
Closed
sommydregent wants to merge 1 commit into
Closed
sommydregent wants to merge 1 commit into
sommydregent wants to merge 1 commit into
Conversation
|
This pull request has merge conflicts with |
…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-debit-split
branch
from
October 26, 2026 11:08
c323ecd to
825b0be
Compare
Malware and injection scan0 blocking, 1 to review, 0 scanner error(s), 11 changed file(s). Needs maintainer review
Scanned commit 825b0be. 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. |
11 tasks done
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).
#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 ascanceland adds areasoncode to theCancelledevent, so churn is visible from chain data alone.cancelis now that function called withCANCEL_REASON_NONE(0), so the event shape is the same whether or not a reason was supplied.CANCEL_REASON_NONEcancelemits)CANCEL_REASON_TOO_EXPENSIVECANCEL_REASON_NOT_USINGCANCEL_REASON_MISSING_FEATURECANCEL_REASON_SERVICE_QUALITYCANCEL_REASON_OTHERThe code set is closed:
reason > MAX_CANCEL_REASONreturnsInvalidCancelReasonbefore any storage is touched. A rejected call cannot alter a mandate, and an indexer never has to handle an unknown code. The codes are exported as public constants from the subscription crate.The reason never changes what the call does — the mandate is cancelled either way — so it can never be used to keep a mandate alive or make one cost more. Only
subscribermay call it (require_auth), and the merchant still usesend_mandate.#74 — one vault call per charge
chargecalledvault.debittwice (merchant share, then fee), repeating the vault's subscription auth check,Subscriptionconfig read, balance read and balance write for the same total. The newvault.debit_split(user, token, to_a, amount_a, to_b, amount_b)does that work once and then makes the same two token transfers, emitting the same oneDebitevent per non-empty leg.chargenow makes a single vault call in every case: a zero protocol fee leaves the second leg empty, which emits a singleDebitexactly as the old single-call path did.amount_amust be positive,amount_bmay be zero, and the total is added withchecked_add— a wrap there would under-charge the subscriber, so it returns the newAmountOverflow.Acceptance criteria
#73
cancel_with_reason,CANCEL_REASON_*constants, theCancelled.reasonfield doc, andCHANGELOG.md.cancelemits 0 —plain_cancel_emits_reason_zeroreadsreasonoff the emitted event XDR.#74
charge_settles_both_legs_through_one_vault_callasserts twoDebitevents plus the merchant/fee/vault balances;zero_fee_pays_merchant_in_fullnow also asserts the singleDebit; all pre-existing fee and balance 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, same world in both runs (real Stellar Asset Contract, funded subscriber, 1% fee,PRICE = 10_000), settling the second charge of an active mandate so both paths start from an equivalent state. The pre-changechargebody (twodebitcalls) was kept temporarily in the test build purely for the comparison and is not part of this PR.charge→ 2 ×vault.debitcharge→ 1 ×vault.debit_splitThe counts are deterministic on the test host (repeated runs give identical numbers). What is saved is one whole cross-contract invocation frame plus the duplicated auth/config/storage work; the two token transfers (one per payee) remain, so this is the floor for a two-recipient settlement.
Tests
cargo test --all: 63 → 78 tests, all passing.New subscription coverage (8): every documented reason accepted; undocumented reason and
u32::MAXrejected withInvalidCancelReasonwhile the mandate staysActive;reasonread back off the event for bothcancel(0) andcancel_with_reason; stranger and merchant rejected withNotSubscriber; call with no authorization rejected; double cancellation rejected;chargeemits exactly twoDebitevents.New vault coverage (7): both legs paid from one balance update; zero second leg emits a single
Debit; combined-total check (neither leg alone exceeds the balance but the sum does, and the balance is untouched after the error); zero/negative legs;i128overflow; and a specific-signer negative auth test — the mocked auth belongs to a different address (MockAuth/MockAuthInvoke) sodebit_splitfails, plus the no-auth case.Both new state-changing surfaces require the right address:
cancel_with_reason→subscriber.require_auth();debit_split→ the configured subscription contract, same asdebit.Interface and event changes (downstream)
Cancelledgains a trailingreason: u32field. Follow-ups needed in payflow-backend (event decoder/indexer), payflow-frontend (pass a reason code when cancelling) and payflow-docs (events reference + error-code page). Recorded underUnreleasedinCHANGELOG.md.AmountOverflow = 6, subscriptionInvalidCancelReason = 12.Subscription::cancel_with_reasonandVault::debit_split(added tocontracts/subscription/src/interfaces.rs).Checklist
cargo fmt --all -- --checkcargo clippy --all-targets -- -D warningscargo test --all— 78 passed, 0 failedwasm32v1-nonetarget:payflow_subscription.wasm41 376 B,payflow_vault.wasm16 047 B,payflow_plan_registry.wasm15 451 B. The Stellar CLI was not available in this environment, socargo build --target wasm32v1-none --releasewas used in place ofstellar contract build; CI will still run the CLI job.Closes #73
Closes #74