Skip to content

feat(subscription): optional cancellation reason and single-call charge settlement - #160

Closed
sommydregent wants to merge 1 commit into
MMM-pay:mainfrom
sommydregent:issue-73-74-cancel-reason-and-debit-split
Closed

sommydregent wants to merge 1 commit into
MMM-pay:mainfrom
sommydregent:issue-73-74-cancel-reason-and-debit-split

Conversation

@sommydregent

Copy link
Copy Markdown

Summary

Two related changes: how a mandate ends (#73) and how a charge settles (#74).

#73 — optional cancellation reason

cancel gave a merchant no signal about why a subscriber left. The new cancel_with_reason(subscriber, mandate_id, reason) performs exactly the same cancellation as cancel and adds a reason code to the Cancelled event, so churn is visible from chain data alone. cancel is now that function called with CANCEL_REASON_NONE (0), so the event shape is the same whether or not a reason was supplied.

Code Constant Meaning
0 CANCEL_REASON_NONE No reason given (what cancel emits)
1 CANCEL_REASON_TOO_EXPENSIVE Too expensive
2 CANCEL_REASON_NOT_USING No longer using the service
3 CANCEL_REASON_MISSING_FEATURE Missing a needed feature
4 CANCEL_REASON_SERVICE_QUALITY Unhappy with the service
5 CANCEL_REASON_OTHER Something else

The code set is closed: reason > MAX_CANCEL_REASON returns InvalidCancelReason before 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 subscriber may call it (require_auth), and the merchant still uses end_mandate.

#74 — one vault call per charge

charge called vault.debit twice (merchant share, then fee), repeating the vault's subscription auth check, Subscription config read, balance read and balance write for the same total. The new 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, emitting the same one Debit event per non-empty leg.

charge now makes a single vault call in every case: a zero protocol fee leaves the second leg empty, which emits a single Debit exactly as the old single-call path did. amount_a must be positive, amount_b may be zero, and the total is added with checked_add — a wrap there would under-charge the subscriber, so it returns the new AmountOverflow.

Acceptance criteria

#73

  • Codes documented — doc-comment table on cancel_with_reason, CANCEL_REASON_* constants, the Cancelled.reason field doc, and CHANGELOG.md.
  • Plain cancel emits 0 — plain_cancel_emits_reason_zero reads reason off the emitted event XDR.

#74

  • Measured resource reduction reported — see the table below.
  • Same balances and events as before — charge_settles_both_legs_through_one_vault_call asserts two Debit events plus the merchant/fee/vault balances; zero_fee_pays_merchant_in_full now also asserts the single Debit; 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-change charge body (two debit calls) was kept temporarily in the test build purely for the comparison and is not part of this PR.

Path Instructions Memory (bytes)
before: charge → 2 × vault.debit 737 289 222 678
after: charge → 1 × vault.debit_split 675 168 213 048
saved per charge 62 121 (-8.43%) 9 630 (-4.32%)

The 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::MAX rejected with InvalidCancelReason while the mandate stays Active; reason read back off the event for both cancel (0) and cancel_with_reason; stranger and merchant rejected with NotSubscriber; call with no authorization rejected; double cancellation rejected; charge emits exactly two Debit events.

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; i128 overflow; and a specific-signer negative auth test — the mocked auth belongs to a different address (MockAuth/MockAuthInvoke) so debit_split fails, 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 as debit.

Interface and event changes (downstream)

  • Breaking event change: Cancelled gains a trailing reason: u32 field. 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 under Unreleased in CHANGELOG.md.
  • New error codes are appended so existing codes keep their numbers: vault AmountOverflow = 6, subscription InvalidCancelReason = 12.
  • New public functions: Subscription::cancel_with_reason and Vault::debit_split (added to contracts/subscription/src/interfaces.rs).

Checklist

  • cargo fmt --all -- --check
  • cargo clippy --all-targets -- -D warnings
  • cargo test --all — 78 passed, 0 failed
  • wasm release build for all three contracts via the same wasm32v1-none target: payflow_subscription.wasm 41 376 B, payflow_vault.wasm 16 047 B, payflow_plan_registry.wasm 15 451 B. The Stellar CLI was not available in this environment, so cargo build --target wasm32v1-none --release was used in place of stellar contract build; CI will still run the CLI job.

Closes #73
Closes #74

@github-actions

Copy link
Copy Markdown

This pull request has merge conflicts with main. Please merge main into your branch (or rebase) and push. It will be merged automatically once CI passes.

…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
sommydregent force-pushed the issue-73-74-cancel-reason-and-debit-split branch from c323ecd to 825b0be Compare October 26, 2026 11:08
@github-actions

Copy link
Copy Markdown

Malware and injection scan

0 blocking, 1 to review, 0 scanner error(s), 11 changed file(s).

Needs maintainer review

Rule Location Finding Excerpt
long-base64 contracts/subscription/test_snapshots/events/cancelled.xdr:2 Long base64 blob; encoded payloads are a common way to hide code. spec: 000000050000002c456d6974746564207768656e206120737562736372696265722063616e63656c732061206d616e646174652e000000000000000943616e63656c6c65640000000000000...

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.

@github-actions github-actions Bot added the security-review Flagged by the malware scan; needs a maintainer label Oct 26, 2026
@sommydregent
sommydregent deleted the issue-73-74-cancel-reason-and-debit-split branch October 26, 2026 11:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

security-review Flagged by the malware scan; needs a maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(subscription): pay merchant and fee in one vault call feat(subscription): optional cancellation reason

1 participant