DEV Community

Cover image for Building an In-App Rescheduling Flow That Actually Confirms the Change
FinClip Super-App
FinClip Super-App

Posted on

Building an In-App Rescheduling Flow That Actually Confirms the Change

A customer has a broadband installation booked for Friday morning. On Thursday, a work meeting moves into the same time slot. They open the provider's app, find their order, and look for a way to change the visit.

The appointment is there. So is a telephone number.

It is a small gap from the customer's perspective. The company already knows the address, the booking, and the service being installed. Asking someone to explain all of that again feels unnecessary, particularly when the call has to fit into a working day.

For the developer, adding a date picker is probably the easiest part. The harder question arrives after the customer taps Saturday morning: has the installation actually moved? What happens to Friday if Saturday fills up? If the connection drops, which appointment should the customer plan around?

Let's work through this hypothetical service. The goal is an understandable result, with a sensible way to get help when the system cannot complete the change. A team could build this journey into an existing app as a native feature or a mini app; the scheduling responsibilities remain much the same.

Give the confirmation screen something reliable to say

Before choosing UI components, agree with the scheduling team on what each response means. A request can arrive successfully while the installation remains unchanged. An HTTP success response from an intermediary may only acknowledge receipt.

For asynchronous work, 202 Accepted means processing has been accepted and may still fail. It does not establish that the business action succeeded. That distinction is explicit in MDN's explanation of 202 Accepted.

Here is the small vocabulary used in this example:

State What is known What the customer should see
received The service recorded the request; no replacement is confirmed. “We've received your request to move the visit.”
processing The change is underway, or its outcome is being checked. “We're checking the appointment change. You don't need to submit it again.”
confirmed The scheduling system committed the replacement. The confirmed date and time, with the updated booking reference.
unavailable The requested slot was rejected without changing the booking. “That time is no longer available. Your existing appointment is unchanged.”
needs_support The automated path encountered an exception that requires follow-up. A specific explanation and a route to someone who can continue the request.

The wording can be warmer than these examples. Its certainty should match the evidence. A green tick beside “Request received” can easily look like a completed change, especially to someone glancing at the screen between meetings.

On the selection screen, keep Friday visible while the customer looks at alternatives. Before submission, show the old appointment and the requested replacement together. If changing the visit carries a fee or affects another service, surface that condition before the final tap, using information from the relevant backend. A customer should not have to infer whether selecting Saturday has already cancelled Friday. Clear button labels and a short explanation can remove that uncertainty without adding another tutorial.

Available times also need to come from the scheduling service. The displayed list can become stale before submission, so the backend checks availability again when committing the change. Show the installation location's time zone when there is room for ambiguity.

Keep authority behind the screen

The request body needs an appointment identifier, the requested slot, and an idempotency key. The authenticated customer identity comes from trusted server middleware. A browser or mini app sending userId: "customer-1" does not establish ownership.

The service checks that the authenticated customer owns the appointment before creating a change request. Reading the outcome needs authorization too; knowing a request identifier should not reveal another person's booking.

There is a second boundary underneath that check. The scheduling adapter must support a safe replacement: validate the booking and slot, commit the new reservation, and release the old one together. Until replacement succeeds, the original reservation remains intact. A separate “cancel Friday” call followed by “book Saturday” can leave the customer without either appointment.

Some scheduling systems offer the required transaction. Others need reservation holds, a carefully designed recovery process, or an assisted workflow. If the available API only supports cancellation and fresh booking, this demo does not make that limitation disappear. Establish the backend contract before promising instant rescheduling.

The following runnable example uses an in-memory scheduler to model that contract. It deliberately contains no FinClip SDK calls. Its synchronous update represents a scheduling transaction; JavaScript maps do not provide atomicity across remote services.

Save the three JavaScript blocks below, in order, into one .mjs file. The test data contains synthetic identifiers and time-slot labels.

import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';

class DemoScheduler {
  appointments = new Map([
    ['visit-1', { owner: 'customer-1', slot: 'fri-am', version: 1 }],
    ['visit-2', { owner: 'customer-1', slot: 'fri-pm', version: 1 }],
  ]);
  slots = new Set(['sat-am', 'sat-pm']);
  outcomes = new Map();
  calls = 0;
  loseNextReply = false;

  getAppointment(id) {
    return structuredClone(this.appointments.get(id));
  }

  async replace(op) {
    this.calls++;
    if (this.outcomes.has(op.id)) return this.lookupOutcome(op.id);
    const current = this.appointments.get(op.appointmentId);
    let result;
    if (!current || current.owner !== op.subject ||
        current.version !== op.expectedVersion) {
      result = { status: 'needs_support', reason: 'appointment_changed' };
    } else if (!this.slots.has(op.slot)) {
      result = { status: 'unavailable', reason: 'slot_taken' };
    } else {
      // A real scheduling backend must implement this transaction.
      this.slots.delete(op.slot);
      this.slots.add(current.slot);
      const updated = { ...current, slot: op.slot, version: current.version + 1 };
      this.appointments.set(op.appointmentId, updated);
      result = { status: 'confirmed', slot: updated.slot };
    }
    this.outcomes.set(op.id, result);
    if (this.loseNextReply) {
      this.loseNextReply = false;
      throw new Error('Response lost; outcome is unknown to the caller');
    }
    return structuredClone(result);
  }

  async lookupOutcome(id) {
    return structuredClone(this.outcomes.get(id));
  }
}
Enter fullscreen mode Exit fullscreen mode

The version check catches two changes created against the same appointment. Once one succeeds, the other cannot quietly overwrite it. In this small example, that conflict goes to needs_support; a real service may be able to refresh the booking and let the customer choose again.

Make a second tap return the same request

A customer may tap twice, reopen the app, or resend after a slow response. The endpoint has to recognize which business action those attempts belong to. POST does not provide idempotency by default, as the MDN method reference notes.

Here, the key is scoped to the authenticated customer and bound to the appointment and requested slot. Repeating the same request returns its existing result. Reusing its key for a different appointment or time is rejected. A genuinely new choice receives a new key.

The public submission method and the internal processing method are separate so that receipt cannot accidentally be presented as completion. In production, recording the request and arranging durable processing also need a reliable transactional design.

function createRescheduler(scheduler) {
  const requests = new Map();
  const keys = new Map();
  const view = op => ({ id: op.id, status: op.status, ...op.result });

  function requireSubject(session) {
    if (!session?.subject) throw new Error('Unauthenticated');
    return session.subject;
  }

  function ownedRequest(session, id) {
    const subject = requireSubject(session);
    const op = requests.get(id);
    if (!op || op.subject !== subject) throw new Error('Access denied');
    return op;
  }

  return {
    submit(session, { appointmentId, slot, key }) {
      const subject = requireSubject(session);
      for (const value of [appointmentId, slot, key]) {
        if (typeof value !== 'string' || !value.length || value.length > 128) {
          throw new Error('Invalid request');
        }
      }
      const current = scheduler.getAppointment(appointmentId);
      if (!current || current.owner !== subject) throw new Error('Access denied');
      const scope = JSON.stringify([subject, key]);
      const fingerprint = JSON.stringify([appointmentId, slot]);
      const previous = keys.get(scope);
      if (previous) {
        if (previous.fingerprint !== fingerprint) throw new Error('Key reused');
        return view(requests.get(previous.id));
      }
      const op = {
        id: randomUUID(), subject, appointmentId, slot,
        expectedVersion: current.version, status: 'received',
      };
      requests.set(op.id, op);
      keys.set(scope, { id: op.id, fingerprint });
      return view(op);
    },

    // Internal worker entry point, not a public unauthenticated endpoint.
    async process(id) {
      const op = requests.get(id);
      if (!op) throw new Error('Unknown request');
      if (op.status !== 'received') return view(op);
      op.status = 'processing';
      try {
        op.result = await scheduler.replace(op);
        op.status = op.result.status;
      } catch {
        // The backend may already have committed. Query its outcome.
      }
      return view(op);
    },

    async refresh(session, id) {
      const op = ownedRequest(session, id);
      if (op.status === 'processing') {
        const result = await scheduler.lookupOutcome(id);
        if (result) {
          op.result = result;
          op.status = result.status;
        }
      }
      return view(op);
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Notice the restrained behavior after an exception. A lost reply can happen after Saturday has already been booked. The operation stays processing until a lookup establishes its outcome. The UI should not announce failure, promise that Friday still applies, or start another replacement based only on a timeout.

An unresolved lookup also needs an operational limit. Define when a support queue takes ownership, how the customer receives an update, and who can reconcile the booking. This demo leaves unknown outcomes pending; it does not implement that queue or a background retry worker.

Test the awkward moments before polishing the calendar

These checks use Node's strict assertion module. They cover authorization, a confirmed change, duplicate requests, changed payloads, an unavailable slot, a lost response, and competing changes.

const customer = { subject: 'customer-1' }; // Trusted session in production.
const stranger = { subject: 'customer-2' };
const input = { appointmentId: 'visit-1', slot: 'sat-am', key: 'tap-1' };
const fresh = () => {
  const scheduler = new DemoScheduler();
  return { scheduler, service: createRescheduler(scheduler) };
};

{
  const { scheduler, service } = fresh();
  const before = scheduler.getAppointment('visit-1');
  assert.throws(() => service.submit(stranger, input), /Access denied/);
  assert.throws(() => service.submit(null, input), /Unauthenticated/);
  assert.deepEqual(scheduler.getAppointment('visit-1'), before);
  assert.equal(scheduler.calls, 0);
}
{
  const { scheduler, service } = fresh();
  const accepted = service.submit(customer, input);
  assert.equal(accepted.status, 'received');
  assert.equal(scheduler.getAppointment('visit-1').slot, 'fri-am');
  assert.equal((await service.process(accepted.id)).status, 'confirmed');
  assert.equal(scheduler.getAppointment('visit-1').slot, 'sat-am');
  assert.equal(service.submit(customer, input).id, accepted.id);
  await service.process(accepted.id);
  assert.equal(scheduler.calls, 1);
  assert.throws(() => service.submit(customer, { ...input, slot: 'sat-pm' }), /Key reused/);
  assert.throws(() => service.submit(customer, { ...input, appointmentId: 'visit-2' }), /Key reused/);
  await assert.rejects(service.refresh(stranger, accepted.id), /Access denied/);
}
{
  const { scheduler, service } = fresh();
  const before = scheduler.getAppointment('visit-1');
  const accepted = service.submit(customer, { ...input, slot: 'sold-out' });
  assert.equal((await service.process(accepted.id)).status, 'unavailable');
  assert.deepEqual(scheduler.getAppointment('visit-1'), before);
}
{
  const { scheduler, service } = fresh();
  scheduler.loseNextReply = true;
  const accepted = service.submit(customer, input);
  assert.equal((await service.process(accepted.id)).status, 'processing');
  assert.equal(service.submit(customer, input).status, 'processing');
  assert.equal((await service.refresh(customer, accepted.id)).status, 'confirmed');
  assert.equal(scheduler.calls, 1);
  assert.equal(scheduler.getAppointment('visit-1').slot, 'sat-am');
}
{
  const { scheduler, service } = fresh();
  const first = service.submit(customer, input);
  const second = service.submit(customer, { ...input, slot: 'sat-pm', key: 'tap-2' });
  assert.equal((await service.process(first.id)).status, 'confirmed');
  assert.equal((await service.process(second.id)).status, 'needs_support');
  assert.equal(scheduler.getAppointment('visit-1').slot, 'sat-am');
}
console.log('All rescheduling checks passed.');
Enter fullscreen mode Exit fullscreen mode

Run the saved file with Node.js. Passing these checks verifies the demonstration's behavior. It says nothing about the transaction guarantees of a provider's real scheduling API.

Every map here disappears on restart and is private to one process. Production needs persistent requests, durable idempotency records, safe worker coordination, and defined retention. Real booking eligibility can include service areas, engineer skills, notice periods, and access requirements. Those rules belong with authoritative scheduling data. Authentication middleware, transport security, rate limits, and an actual HTTP interface are outside this sample.

Leave the customer with a usable next step

When an exception needs a person, carry forward the request reference, the permitted booking context, the requested time, and the last verified result. Explain what is being shared. Support should be able to retrieve that context through an authorized system instead of asking the customer to start again.

Avoid putting addresses, telephone numbers, tokens, or free-text customer notes into routine diagnostic logs. An opaque operation identifier and a status code can help correlate events, with appropriate access controls and retention. The demonstration prints only its test result.

The existing app still needs an entry point customers can find. “Manage installation” beside the appointment is a plausible starting place. After confirmation, refresh the booking summary so the old date does not remain elsewhere on screen. If push or email confirmation is provided, derive it from the committed result and handle notification failure separately from booking failure.

Test that screen with someone unfamiliar with the implementation. Ask which appointment they believe they currently have and whether they think another action is required. Hesitation often points to wording or stale information that a successful API test will miss. Include a slow connection in the exercise; that is when reassuring but premature messages tend to cause trouble.

For teams considering mini apps, this is a concrete service to evaluate. FinClip's introduction describes integrating its runtime SDK into a host application so that the app can run mini programs. That can provide a place to deliver the rescheduling interface within an existing app. The runtime does not supply installation availability, customer authorization, or the scheduling transaction described here; those need working connections to the business systems.

Before expanding further, observe whether customers finish this particular journey. Separate successful changes from unavailable slots, unresolved requests, and voluntary choices to contact support. A lower call count alone can hide people who gave up.

For the person rearranging Friday morning, a useful ending is modest: Saturday is confirmed, the app agrees, and there is no need to call just to check.

Top comments (0)