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 / longitudeover 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/checkoutand 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’serrorstring 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_idin 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 isEXPIREDorCANCELEDfor a token CleanOps has never seen. Workersmark_failedimmediately; the notification stops looping in the Oban retry queue. -
Retryable (
:purchase_pending_validation) — when the notification isPURCHASEDfor an unknown token. This is the legitimate mobile-validate race (Google sometimes fires the RTDN before the mobile client has hit our/validateendpoint). 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.