Dentolize · Paymob Fallback Email Walkthrough
On this pageBusiness viewTechnical view

Paymob Fallback Email

Business view

When a clinic wants a patient to pay online — for an invoice balance, a diagnostic fee, or a deposit — staff click Generate Online Link, and Dentolize asks the clinic's payment provider (Geidea, Paymob, Fawry, or Stripe) to create a hosted checkout page. The patient gets a link (by WhatsApp, SMS, or however the clinic shares it), pays, and the invoice updates automatically.

A patient's email address is optional everywhere in Dentolize — it's not required to register a patient, and it's not required on the "Generate Online Link" form itself (only a phone number or an email is needed, not both). So plenty of patients simply don't have one on file.

For clinics using Paymob specifically, that combination — no email, Paymob provider — used to be a problem. Paymob's link-creation API expects an email field in the request. Dentolize was building that field as email || undefined, and when there's no email, undefined values vanish from the outgoing request entirely (that's how JSON serialization works). The request Paymob received simply had no email key at all, which could cause the request to fail, blocking link generation for that patient.

The fix is a fallback: if the patient has no email, Dentolize now sends a placeholder address, online_payment@dentolize.com, instead of leaving the field out. The patient still doesn't need to have an email — Dentolize supplies one on their behalf, addressed to Dentolize itself (not the patient), purely so Paymob's API always gets the field it expects.

This is a backend-only change. No screen, button, or field moved — staff generate payment links exactly as before. The only difference is that it now works reliably for patients without an email when the provider is Paymob.

Technical view

Where the fix lives

handleNewOnlinePayment() in packages/server/src/resolvers/mutations/mutationUtils/onlinePaymentsUtils.js is the single function that builds and sends the outgoing request to whichever payment provider a PaymentOption is configured for. It branches on data.provider (GEIDEA, PAYMOB, FAWRY, STRIPE, ...). The change is scoped to the PAYMOB branch only.

Before (from the PR diff):

const postData = {
  ...
  email: email || undefined,
  ...
  shipping_data: email
    ? {
        phone_number: `${code}${phone}`,
        email: email || undefined,
        ...
      }
    : undefined,
  ...
}

After — onlinePaymentsUtils.js:196 and onlinePaymentsUtils.js:200:

const postData = {
  ...
  email: email || 'online_payment@dentolize.com',
  ...
  shipping_data: email
    ? {
        phone_number: `${code}${phone}`,
        email,
        ...
      }
    : undefined,
  ...
}

Two distinct edits, one behavioral:

  1. email (top-level, line 196) — this is the actual fix. undefined

fields are dropped by JSON.stringify (and therefore by axios's default JSON request serialization), so email || undefined meant "omit the key entirely" whenever the patient had no email. Paymob's /api/ecommerce/payment-links endpoint expects an email field on the customer; requests missing it were sent regardless, but the field is now always populated, either with the real patient email or the fallback address.

  1. shipping_data.email (line 200) — a pure simplification, not a

behavior change. shipping_data is only built at all inside the email ? {...} : undefined ternary, so by the time email: email || undefined executed inside that block, email was already known to be truthy — the || undefined there was dead code. It's now written as plain email.

Where email comes from

handleNewOnlinePayment receives email as a parameter; it doesn't look it up itself. Every caller sources it the same way — the explicit value passed in by the caller (usually a form field), falling back to the patient's stored email (Patient.email, an optional String? column in packages/prisma/schema.prisma):

CallerFileEmail source
Generate Online Link (invoice)packages/server/src/resolvers/mutations/actions/generateOnlinePayment.js:31`args.email \\invoice.patient.email`
Collect diagnostic feespackages/server/src/resolvers/mutations/actions/appointments/collectDiagnosticFees.js:99appointment.patient.email
Book appointment with online paymentpackages/server/src/resolvers/mutations/actions/appointments/addNewAppointment.js:120patient.email
Register patient with online paymentpackages/server/src/resolvers/mutations/actions/patient/savePatientDetails.js:366args.email

Because all four routes funnel through the same shared handleNewOnlinePayment helper, the fix applies to all of them automatically — none of those four files were touched by this PR.

On the frontend, the invoice flow's email input (packages/clinic-web/src/components/common/formFields/EmailField.js) is rendered with required = false by default, and GeneratePaymentModal.js:220 doesn't override that — confirming a blank email is an expected, reachable state, not a data-entry mistake.

Why only Paymob

The other three providers build their request payloads differently and were untouched by this diff:

  • Geidea (onlinePaymentsUtils.js, GEIDEA branch) sends

customer: { name, email, ... } with no || undefined fallback logic — email is passed through as-is, undefined or not.

  • Fawry doesn't send a customer email in its payments/init payload at

all.

  • Stripe builds its own request shape further down the same function,

independent of this code path.

So this PR narrowly targets the one provider whose API is picky about a missing email key, without touching the other three integrations' request shapes or error handling.

What didn't change

  • No GraphQL schema change — generateOnlinePayment already accepted an

optional email: String argument (packages/server/src/schema.graphql:1214). No new argument was added.

  • No database migration — Patient.email was already optional.
  • No new validation — the frontend EmailField was already optional before

and after this PR; this fix doesn't make email required anywhere, it just makes the Paymob request always well-formed even when it's absent.