Release Notes

Stay up to date with the latest features, improvements, and fixes

Cross-Provider Subscription Safety + Notify: On The Way

May 12, 2026

Latest

Headline features

“Notify: On The Way” Customer Alert

A new one-tap action in the Job details actions menu (web LiveView + mobile) that sends an instant notification to the customer that the tech is en route. Optional ETA field (max 40 chars, blank = no ETA in the message).

Where: Job details → actions menu → Notify: On The Way.

Who can send: assigned technicians on the job AND employers / admins on the same account.

Job states it’s blocked on: completed, cancelled, archived (returns a “can’t send on a finished job” error).

What the customer gets: a tech_on_the_way instant alert via the channels enabled on the template (in-app for registered customers, SMS / email for unregistered customers with contact info). The template body uses a {{eta_clause}} variable so the same template covers both with-ETA and no-ETA cases — no template fork.

Double-submit safety: the mobile send button is disabled while the request is in flight; on web, the LV’s processing flag blocks the re-submit.

Tappable job addresses + Navigate to Job

Job addresses now open external maps with one tap, from every surface where a job address is shown.

New JobAddressLink component renders the address as tappable text and, on tap, opens an action sheet with Open Job Details / Open in Maps / Cancel. Wired into five surfaces covering seven screens:

  • JobCard — employer dashboard + technician dashboard
  • JobHistoryCard — job history list + the customer detail Jobs tab
  • Schedule JobDetailModal — when a job card is opened from the calendar
  • Customer dashboard — inline job card

Maps utility (src/utils/maps.ts):

  • iOS → Apple Maps via the maps: URL scheme
  • Android → Google Maps via the geo: URL scheme
  • Prefers latitude / longitude over the formatted address when both are present (more accurate routing)

“Navigate to Job” action menu item added to the job detail action menu for both employers and assigned technicians — same destination, fewer taps when you’re already on the job screen.

Cross-Provider Subscription Safety

Operators could previously end up with subscription rows from two providers at once — a web Stripe sub on top of a Google Play sub, an Apple sub on top of a Stripe sub, etc. — when a manual cancellation on one provider didn’t match what CleanOps saw locally. That class of bug is closed across the entire surface.

The guarantee: if a user has a live subscription on any provider, every entry point that could create a new one on a different provider refuses the request and tells the user which provider is currently active.

Where the guard fires:

  • Web Stripe Checkout (POST /api/v1/subscription/checkout and the LiveView Subscribe button) → returns 409 with {error, code: "other_provider_active", active_provider} JSON; the LV renders a provider-specific flash.
  • Mobile IAP validate for Apple (POST /subscription/iap/apple/validate) and Google (POST /subscription/iap/google/validate) → same 409 shape; the mobile picker and Settings → Billing surface the backend’s error string in an Alert.
  • Stripe webhooks (customer.subscription.created / .updated) — if the local row belongs to a live IAP sub, the webhook handler refuses to overwrite it.
  • Google RTDN + Apple ASSN — both IAP handlers consult the local provider before upserting.
  • Login auto-checkout — if a user logs in with a price_id in the post-login redirect and the account already has a live other-provider sub, the auto-checkout aborts and the login flash names the provider that needs to be canceled first.
  • Settings → Billing mount on the web LiveView — already running a Stripe checkout flow against a live IAP sub now flashes the same message.

Provider-specific messages users see:

  • Stripe holds a live sub: “You have an active subscription via Stripe (the web). Cancel it from the billing page before subscribing here.”
  • Google Play holds it: “You have an active subscription via Google Play. Cancel it in the Play Store before subscribing here.”
  • Apple holds it: “You have an active subscription via the App Store. Cancel it in your Apple subscriptions settings before subscribing here.”

Under the hood (for the curious): a new Subscriptions.Context.ProviderTakeover module defines dead?/1 per provider — Google requires subscriptionState in [EXPIRED, CANCELED] AND past expiry; Apple requires status in [expired, canceled, revoked] AND past expiry; Stripe walks status + cancel_at_period_end + current_period_end. claim_for_provider/2 produces the column-nilling map so a takeover wipes the previous provider’s columns cleanly. The DB-write floor (upsert_for_provider/3) refuses cross-provider overwrites regardless of caller, so even legacy code paths can’t accidentally clobber.


Also in this release

A handful of bug fixes and reliability work folded into the same cut.

Stripe Connect callback no longer crashes

stripe_callback_controller.ex was reading a :onboarding_complete field that doesn’t exist on the schema, crashing every return-from-Stripe redirect for accounts mid-onboarding. New StripeConnect.onboarding_complete?/1 derives completion from the Stripe-side stripe_account_details (details_submitted && charges_enabled), used by both the controller and the integrations JSON payload (no logic drift).

Login routes new users to the right screen

UserAuth.log_in_user/3 was calling redirect/2 internally; callers piped the already-sent conn into a second redirect/2 for the company-setup or subscription bounce, raising Plug.Conn.AlreadySentError on every onboarding-flow login. The first redirect (to /) is what landed; the intended bounce to /company_setup / /subscription was silently dropped — new users were misrouted to the dashboard. log_in_user/3 now returns the conn without redirecting; all five callers redirect explicitly. New users actually land on /company_setup.

LiveView notification hook stops crashing pages

NotificationHook.show_notifications is mounted globally; its attach_hook was returning {:cont, ...} for the :new_notification message, which forwarded it to the host LiveView’s own handle_info — crashing any LV that didn’t have a matching clause. ExpensesLive was the visible victim in prod. Changed to {:halt, ...} — the hook already applied the assign, no reason to forward.

Google RTDN webhook stops looping on unknown canceled tokens

Unknown purchase tokens in Google’s Real-Time Developer Notifications are now classified at receipt:

  • Terminal (:subscription_not_found) — when the notification is EXPIRED or CANCELED for a token CleanOps has never seen. Workers mark_failed immediately; the notification stops looping in the Oban retry queue.
  • Retryable (:purchase_pending_validation) — when the notification is PURCHASED for an unknown token. This is the legitimate mobile-validate race (Google sometimes fires the RTDN before the mobile client has hit our /validate endpoint). The worker keeps retrying with backoff; the subscription appears once the mobile client validates.

Before the fix, both cases tagged :subscription_not_found, so canceled unknown tokens looped indefinitely.