Cancellations & refunds — where we are and what’s left
The rules live in docs/reference/israeli-cancellation-rules.md
(source: אמון הציבור, https://www.emun.org/cancellation-guide/ ). This document is only the
engineering picture: what the code does, what’s fixed, what’s left.
Supersedes israeli-cancellation-law-audit.md, whose Part A was assembled from Wikisource
transcriptions and got the scope of the gym fee rule wrong. Its Part B (code audit) is the
basis of the table below.
What Taikan sells, and which regime each falls under
| Product | Regime | Consequence |
|---|---|---|
| Rolling monthly subscription | עסקה מתמשכת — continuous | Cancellable any time; charging stops within 3 days of notice; gym rule adds a one-month effective date (see Q1) |
| Punch card (one-off, with expiry) | Not continuous — one-off service purchase | 14 days from purchase or the written document, whichever is later |
| Drop-in (single session) | Not continuous | 14 days, but notice must land ≥2 days before the session |
No committed-term memberships. That matters more than anything else below.
The cancellation fee is structurally zero
The gym cancellation fee is (committed monthly price − alternative-track monthly price) × months elapsed, capped by thirds at 25/20/17%. It exists to let a gym recover the discount a member got
for committing. A rolling monthly plan has no committed price and no alternative track, so the
difference is zero and the caps never bind.
There is no fee engine to build. No alternative_monthly_price column, no thirds arithmetic,
no cancellation_settlements table. This was the largest single item in the earlier plan and it
is gone on the merits, not merely deferred.
Code state
Fixed in this branch (feat/cardcom-followups):
| Was | Now |
|---|---|
Charge cron had no cancel_at_period_end filter, so it charged members who had cancelled and pushed current_period_end forward — losing the cancellation permanently, every interval | Excluded in the due query and re-checked under the row lock; regression-tested across both crons |
A pending cancellation request did not stop billing | Excluded in the due query too — the notice is the notice, regardless of whether staff got to it |
Cancelling required status === 'active' | Any live status can cancel; only an already-cancelled subscription is refused |
| A scheduled plan change blocked cancellation with a 409 | Cancellation supersedes and clears the schedule |
| Reason was mandatory (API + UI) | Optional everywhere, label marked optional in en/he/ru |
Tranzila’s refund() fabricated a trz_ref_<uuid> and reported success | Throws; unreachable today (getRefundCapability() === 'manual') but no longer a landmine |
Still open:
| # | Gap | Where | Size |
|---|---|---|---|
| 1 | No cancellation effective date. Everything keys off current_period_end, a billing artefact. Neither “3 days from notice” nor “one month from notice” is representable. | subscriptions.service.ts sweep + schema | Blocked on Q1 |
| 2 | No notice record. We store cancellation_requested_at (an app event) but not the channel or a delivered-at date. Phone/email/in-person notices have no intake at all. | schema + a staff intake | Medium |
| 3 | No 14-day cooling-off for punch cards and drop-ins, and no contract_document_delivered_at to start the clock lawfully. | plans purchase flow | Medium |
| 4 | No pro-rata refund on cancellation. applyImmediateCancel cancels and returns nothing. | subscriptions.service.ts:1128+ | Small once Q1 lands |
| 5 | Manual refund tasks have a 3-day due date nothing enforces, and no refund SLA exists. | payment.service.ts:619+ | Small |
| 6 | refund() resolves the adapter from the org’s current provider, not txn.provider — a refund after a gateway switch calls the wrong PSP. | payment.service.ts:454 | Small |
| 7 | Two silent paths: staff immediate cancel and automatic refunds notify the member of nothing. | subscriptions.service.ts:1128+, payment.service.ts:507+ | Small |
| 8 | No ID number captured on a cancellation notice — the one identifier we are actually entitled to ask for. | schema + UI | Small |
| 9 | No country / legal-entity-name / ח.פ on organizations, so the required business-identity and cancellation-channel disclosure cannot be rendered on any invoice or receipt. | schema + disclosure block | Medium |
| 10 | No signed-out cancellation route, and no prominent home-page link on minisites. A member who cannot sign in cannot give notice. | minisites + a public endpoint | Medium |
| 11 | Pending/incomplete payments cannot be revoked from the dashboard — abandoned checkouts sit as pending rows forever. | payments dashboard | In progress |
Open questions
Q1 — which effective date governs a rolling monthly membership? The guidance states the one-month rule for gym memberships without limiting it to fixed terms, but states it beside a fee formula that only makes sense for a committed term. The continuous-transaction rule says charging stops within 3 days of notice. For a rolling monthly plan these give different answers, and the difference is up to a month of membership.
This is the only thing blocking the effective-date work, and it is a lawyer question, not an
engineering one. Design accordingly: put the rule in one pure function
(computeCancellationEffectiveAt) over an explicit stored notice date, so the answer changes one
function rather than the schema.
Q2 — 3 days or 3 business days? The guidance says 3 days; the statute reportedly says three business days. Implement 3 calendar days: it is the shorter and safer of the two, and removes the need for an Israeli business-day calendar entirely.
Q3 — do we accept notices from people who cannot sign in? Email and phone are mandatory channels regardless of login state, which implies a public intake and an unmatched-notice queue. That is a new abuse surface and a product decision.