External payment links — status
Billing members through a payment provider Taikan has no API access to. The gym creates payment pages in its own provider dashboard, pastes them onto Taikan plans, and Taikan learns about payments from inbound webhooks.
Design and rationale: ADR-0017.
Branch: feat/external-payment-links. The tier itself is unconditional since
2026-08-22 — external-payment-links (the old master gate) was merged
permanently ON and its key, gate, and off-branches deleted. One flag remains:
external-payment-auto-apply (unattended crediting), per-org and fail-closed.
Status (2026-08-08): presale-ready as Plan B. Strategic decision: the provider partner program (Cardcom/PayPlus) is the rail; this tier serves the Erez presale window (one-off purchases) and remains a documented fallback. The critical member-safety gaps below are CLOSED; the at-scale gaps (standing-order cycles, lapse sweep) are deliberately unbuilt — they define the ~30-day window this tier can safely carry before the API rail must take over.
Why this exists
Grow quotes ₪600/month for API access. Every serious Israeli provider gates tokenization, recurring and invoicing behind separately-priced modules. But a plain Grow merchant account can create payment links and self-serve webhooks for free, which is enough to run membership billing if Taikan is willing to observe payments rather than initiate them.
Commercial position (2026-08-05): this is not only a fallback for gyms without API access. At high volume it is the better route. A 400-member gym at ₪450 average pays ₪2,058/month on Taikan + its own Grow account at 0.75%, versus ₪2,384 on Taikan + a Cardcom terminal at 1%. Erez (~400 subs) stays on this path unless Cardcom quotes better than 0.857%.
M1 — done and verified
| Area | What |
|---|---|
| Schema | external_payment_events + plans.payment_link_url + 3 enums (migration 0100) |
| Ingestion | POST /webhooks/external/:provider/:orgId, org + optional plan/token/kind in the URL |
| Verification | Constant-time compare of Grow’s body webhookKey against stored credentials |
| Idempotency | UNIQUE (organization_id, external_reference) on the event table |
| Matching | Expectation-first: identity resolves the member, then filters open expectations |
| Apply | Single transaction: completes/creates the ledger row, advances the subscription, flips membership to current |
| Owner UI | Payments → “Awaiting confirmation” tab, plus the same queue on the member detail page |
| Plan UI | paymentLinkUrl field on the plan dialog |
| Provider config | Grow entry reduced to a single webhookKey credential |
Evidence. Payload contract reverse-engineered from a real ₪1 payment
(2026-08-05), not from documentation. 13 parser unit tests. A full local
purchase produced exact (matched via email, one open expectation), and manual
confirmation correctly moved the subscription to active with a 30-day period,
completed the transaction with the asmachta stamped, set membership
payment_status = current, and closed the review task. The unique constraint
was verified to reject a duplicate reference.
Gaps
Ordered by risk. Nothing here is scheduled; each needs a decision.
1. Members can cancel a subscription Taikan cannot stop — CLOSED 2026-08-08
Member cancel, plan change (member AND staff, every entry point via the shared
eligibility matrix) and renew are refused with EXTERNALLY_BILLED (409) when
the sub’s plan carries a payment link and no card is on file. The member UI
hides those controls and shows “billing is managed by the gym” instead
(profile/payments card, profile renew button, shop switch sources).
2. Nothing marks a lapsed subscription past_due — HIGH
Link-mode subs carry next_charge_date = NULL so the recurring cron cannot see
them (verified against its WHERE clause). When a period ends with no payment,
the subscription stays active indefinitely with full booking access. The
Phase 3 lapse sweep was never built.
Needs: a sweep with a grace period long enough to absorb webhook delivery lag.
3. Standing-order cycles queue every month — HIGH (blocks Erez)
A הוראת קבע cycle arrives with no open expectation, so it can never rate
exact. At 400 members that is 400 manual confirmations a month, which defeats
the point. Cycle matching (attribute to whoever paid the previous cycle) is
ADR Phase 2 and unbuilt.
4. Failed standing orders: task copy fixed, dunning still unwired — MEDIUM
The recurring_failed kind is forced to confidence none so it can never
credit, and (fixed 2026-08-08) its task now reads “Standing order failed — do
not confirm this as a payment” with chase-the-member instructions, at high
priority. Still unwired: dunning — no past_due, no debt accrual, no member
notification. The ladder in handlePaymentFailed is not connected to this
path. Payload shape still unobserved.
5. Abandoned checkouts never expire, and they poison matching — MEDIUM
A pending subscription and transaction linger forever.
PaymentMonitoringService emits payment.stuck_pending telemetry but nothing
acts on it and no owner ever sees it. Worse, stale expectations are what the
matcher matches against: two abandoned ₪100 checkouts make the next real ₪100
payment ambiguous, so auto-apply quietly degrades as volume grows.
6. Matching has no guarantee, only a strong heuristic — MEDIUM
Same-price collisions only bite when payer identity fails to resolve, so the failure rate tracks “payer used a different email/phone than their account” rather than gym size. Ambiguity always produces a queue item, never a wrong credit. But there is no structural guarantee.
Needs: test whether a Grow link URL accepts a query parameter that echoes back
in the payload (exact matching, ambiguity gone), or failing that a prefill
parameter for email/phone (forces identity resolution). Cardcom’s ReturnValue
solves this structurally, which is the argument for that rail.
7. No tests on the matcher or the apply service — CLOSED 2026-08-08
external-payment-flow.int.spec.ts: 14 integration tests against the real test
DB covering the matcher’s confidence matrix (incl. cross-member same-amount
disambiguation and dirty-phone normalization on both sides), apply’s
transitions + replay idempotency, and ingestion end to end (verify → match →
auto-apply → dedupe, forged-key rejection, failed-cycle never-credits). The
flag-off parking case was dropped when the master gate was merged ON
(2026-08-22). Plus the 13 parser tests.
8. Member return page misleads after payment — CLOSED 2026-08-08
When polling sees a pending sub on a link-backed plan, the timeout state now renders “Payment received — the gym will confirm your subscription shortly” (green, calm) instead of the generic yellow “processing”. Keys in all three dictionaries.
9. Declined one-off payments are invisible — LOW (for now)
Grow does not fire a webhook on a declined direct transaction (confirmed with Grow), so a decline is indistinguishable from abandonment. The parser reads no status field at all, so if Grow ever does deliver a failure on the account webhook it would be parsed as a success and could be credited. Defensive status check wanted before trusting any payload shape we have not seen.
10. Setup is undocumented in-product — LOW
No settings card shows the owner which URLs to paste into the provider
dashboard. The per-page notify URL and its capability token are supported in
verify() but nothing generates or displays them.
11. Refunding an external-tier payment 500s — CLOSED 2026-08-13
ADR-0017 listed “refunds are entirely out-of-band in the provider’s portal”
under what this tier gives up, but no guard enforced it. PaymentService.refund
resolved the org’s adapter through registry.getOrThrow('external') — and no
external adapter exists or can, since the tier is defined by having no provider
API. The owner got an unhandled 500. Reachable from the UI, not just the API:
the refund action was gated on transaction status alone, and this tier does
produce completed rows.
Now refused with EXTERNALLY_BILLED (409) before provider resolution, and both
owner surfaces (transactions-table.tsx, org-wide; member-payments-tab.tsx,
per member) hide the action and explain why via memberPayments. refundExternallyBilled in all three dictionaries. Keyed on the active provider
alone rather than isExternallyBilled(provider, plan) — the adapter is absent
regardless of any plan’s payment link, so the plan-link half of that helper
would wrongly re-enable the button. Covered by refund-flow.int.spec.ts
(guard lands before provider resolution, nothing mutated) plus one int test per
web surface.
Open experiments
- Per-page notify URL (
קישור לעדכון מערכות מידע) — does it fire, does its payload match the account webhook’s shape, does it carrywebhookKey? - Link query parameters — echo-back or prefill. Decides gap 6, and whether this tier scales past a handful of gyms.
- Standing-order payloads — both a successful cycle and a failed one.
The dev-only capture rig at /webhooks/capture/:label answers all three: point
a webhook at it, pay ₪1, read the JSON it writes to .webhook-captures/.
Local setup
FEATURE_FLAGS=...,external-payment-auto-apply:trueWebhook URL: https://<ngrok>/webhooks/external/grow/<orgId> (the :provider
segment is routing sugar only, never read by the handler — any string works)
Grow’s webhook key goes in Settings → Payments → “Grow (payment links)”.
Redirect back: set Grow’s thank-you page to
http://localhost:3000/he/shop/payment-return?status=success.