Keep Booking and Payment States Separate in a Merchant Mini App
A customer books a haircut inside a wallet's merchant mini app. The payment succeeds while the merchant's scheduling service is still processing the appointment. Which message should the customer see?
If the interface turns the first successful response into a generic confirmation, it may tell the customer that an appointment exists before the scheduling service has accepted it. The integration needs a representation that can hold both outcomes without confusing them.
This tutorial builds a small JavaScript state projection for that purpose. It represents one appointment requiring payment and demonstrates how to handle duplicate receipts, independent arrival order, and conflicting outcomes. It does not make a booking, charge money, verify webhooks, or call a FinClip SDK.
Decide which system can establish each fact
The merchant's booking service establishes whether it accepted the requested appointment. The actual payment provider establishes the payment result. Your application backend combines authorised information from those sources into a customer-facing view.
A successful HTTP request is not sufficient evidence for every business outcome. In particular, HTTP 202 Accepted means that processing has been accepted, without establishing completion. A callback received by the mini app is also different from an authoritative server-side payment record.
Keep the projection behind your backend integration boundary. Server adapters should authenticate incoming information, resolve it to the correct merchant and order, and validate relevant amounts, currencies, and provider references before creating the simplified events used below. The names in this example are application-defined; they are not provider event names.
Represent two independent dimensions
The model begins with a pending booking and a pending payment. A booking can become confirmed or rejected. A payment can become paid or failed. These limited states are enough to expose the important distinction for this appointment example.
The model also records processed event references and a review flag. The review flag handles incompatible terminal information conservatively: it stops the view from selecting a reassuring outcome simply because an event arrived last.
Real projects often require additional states for authorisation, capture, temporary holds, cancellation, refunds, settlement, and fulfilment. Add those states according to the actual business and provider contracts. Do not map every provider status to “paid” just to fit this example.
We intentionally leave fulfilment out of the projection. A confirmed and paid appointment has still not established that the customer attended or received the service.
Run the complete example
Save the following as merchant-service-state.mjs and run node merchant-service-state.mjs. It uses Node.js built-in assertions and no external dependencies. The example was executed with Node.js 24.19.0.
The source property represents information assigned by a trusted server adapter. Reading this property from an arbitrary public request would not authenticate its sender. Similarly, the event reference must originate from the verified source or from a well-defined adapter mapping.
import assert from "node:assert/strict";
export function createOrder(orderId) {
if (typeof orderId !== "string" || !orderId.trim()) {
throw new TypeError("An order reference is required");
}
return { orderId, booking: "pending", payment: "pending",
review: false, seen: [] };
}
const transitions = {
"booking.confirmed": { source: "booking", field: "booking", value: "confirmed" },
"booking.rejected": { source: "booking", field: "booking", value: "rejected" },
"payment.succeeded": { source: "payment", field: "payment", value: "paid" },
"payment.failed": { source: "payment", field: "payment", value: "failed" }
};
// Input must come from verified, authorised server adapters.
// This is a state projection, not a webhook verifier or payment API.
export function applyVerifiedEvent(state, event) {
const rule = Object.hasOwn(transitions, event.type) ? transitions[event.type] : undefined;
if (!rule || event.source !== rule.source) {
throw new Error("Unsupported event or source");
}
if (event.orderId !== state.orderId) {
throw new Error("Event belongs to another order");
}
if (typeof event.id !== "string" || !/^[A-Za-z0-9_-]{1,80}$/.test(event.id)) {
throw new Error("Invalid event reference");
}
const key = event.source + ":" + event.id;
const prior = state.seen.find(item => item.key === key);
if (prior) {
if (prior.type !== event.type) throw new Error("Event reference collision");
return state;
}
const next = { ...state, seen: [...state.seen, { key, type: event.type }] };
const current = state[rule.field];
if (current !== "pending" && current !== rule.value) {
return { ...next, review: true };
}
return { ...next, [rule.field]: rule.value };
}
export function customerMessage(state) {
if (state.review) return "We are checking your booking and payment. Please contact support.";
if (state.booking === "confirmed" && state.payment === "paid") {
return "Booking confirmed. Payment received.";
}
if (state.booking === "rejected" && state.payment === "paid") {
return "Payment received. This appointment is unavailable. Contact support for next steps.";
}
if (state.booking === "confirmed") {
return state.payment === "failed"
? "Reservation confirmed. Payment failed. Check the payment instructions."
: "Reservation confirmed. Payment is still pending.";
}
if (state.booking === "rejected") return "This appointment is unavailable.";
if (state.payment === "paid") return "Payment received. Waiting for merchant confirmation.";
if (state.payment === "failed") return "Payment failed. The booking result is still pending.";
return "Your booking request is being processed.";
}
function event(id, type, orderId = "order-demo") {
return { id, type, orderId, source: type.split(".")[0] };
}
function runExamples() {
const base = createOrder("order-demo");
const paidEvent = event("p1", "payment.succeeded");
const confirmedEvent = event("b1", "booking.confirmed");
const rejectedEvent = event("b2", "booking.rejected");
const paid = applyVerifiedEvent(base, paidEvent);
assert.equal(customerMessage(paid), "Payment received. Waiting for merchant confirmation.");
const booked = applyVerifiedEvent(base, confirmedEvent);
assert.equal(customerMessage(booked), "Reservation confirmed. Payment is still pending.");
const complete = applyVerifiedEvent(paid, confirmedEvent);
assert.equal(customerMessage(complete), "Booking confirmed. Payment received.");
const reverse = applyVerifiedEvent(booked, paidEvent);
assert.equal(customerMessage(reverse), customerMessage(complete));
assert.strictEqual(applyVerifiedEvent(paid, paidEvent), paid);
const anotherReceipt = applyVerifiedEvent(paid, event("p2", "payment.succeeded"));
assert.equal(anotherReceipt.payment, "paid");
const rejectedPaid = applyVerifiedEvent(paid, rejectedEvent);
assert.match(customerMessage(rejectedPaid), /appointment is unavailable/);
assert.equal(rejectedPaid.payment, "paid");
const conflict = applyVerifiedEvent(complete, rejectedEvent);
assert.equal(conflict.review, true);
assert.match(customerMessage(conflict), /checking/);
const failure = applyVerifiedEvent(base, event("p3", "payment.failed"));
assert.equal(failure.booking, "pending");
const laterSuccess = applyVerifiedEvent(failure, paidEvent);
assert.equal(laterSuccess.review, true);
assert.throws(() => applyVerifiedEvent(base,
event("x1", "booking.confirmed", "another-order")), /another order/);
assert.throws(() => applyVerifiedEvent(base,
{ ...confirmedEvent, source: "payment" }), /source/);
assert.throws(() => applyVerifiedEvent(paid,
event("p1", "payment.failed")), /collision/);
assert.throws(() => applyVerifiedEvent(base,
event("x2", "booking.cancelled")), /Unsupported/);
assert.throws(() => applyVerifiedEvent(base,
event("", "booking.confirmed")), /reference/);
assert.throws(() => applyVerifiedEvent(base,
{ id: "x3", type: "constructor", orderId: "order-demo" }), /Unsupported/);
assert.deepEqual(base, createOrder("order-demo"));
console.log("Merchant service state examples passed: 19 assertions.");
}
runExamples();
Understand what the messages establish
When payment arrives first, the projection says that payment was received while merchant confirmation is pending. When the booking arrives first, it reports a confirmed reservation and a pending payment. Only the combination produces the example's message that the booking is confirmed and payment has been received.
These phrases describe the deliberately limited model. A real merchant might only hold an unpaid reservation until a deadline. In that case, retain the expiry and display it. The fact that the scheduling system accepted a temporary reservation should not become an unlimited promise to the customer.
If a payment exists and the merchant rejects the appointment, the projection preserves both facts. It directs the customer towards the next support step. It does not create a refund, promise a timeframe, or assume that another slot will be accepted. Those actions require separate capabilities and policies.
The interface should provide the relevant service reference and support route alongside the message. Do not ask a customer to retry payment while the existing payment outcome remains unresolved.
Handle repeated delivery without counting a new purchase
The event key combines the source and its event reference, because independent systems can use overlapping identifiers. Receiving the same event again returns the existing state. A separate event reporting the same outcome also preserves that outcome instead of adding another payment.
This approach illustrates projection semantics; it does not implement financial accounting. An actual payment integration must identify the underlying payment attempt, distinguish multiple legitimate attempts, and reconcile amounts against the order.
Provider behaviour matters. For example, Stripe's webhook documentation discusses duplicate events and delivery without a guaranteed event order. That is a concrete provider example of why consumers need explicit handling. It does not establish a common delivery contract for every wallet or payment gateway.
Keep request idempotency separate from event deduplication. A repeated booking request can create a second business action before any webhook reaches your system. Where the booking or payment API supports idempotent requests, follow that API's actual key and retry rules.
Treat conflicting outcomes as a reconciliation task
The projection rejects an event for another order or an unsupported source and type. It also rejects reuse of an already processed event reference with a different type. Those checks prevent the sample from silently applying a mismatched event.
When two different events assert incompatible terminal outcomes, the model retains the earlier recorded state and sets the review flag. The customer then sees a checking message rather than an arbitrary winner. The application needs an operational process to resolve that flag.
A production adapter may need to fetch the latest authoritative object, apply a provider-supported revision, or reconcile a distinct payment attempt. An arrival timestamp alone should not be assumed to describe the business order of events. The correct strategy depends on the source system.
This model deliberately does not provide a reconciliation override. Adding one should involve an authenticated backend operation with an audit record and appropriate business rules, rather than a button that lets the client choose its preferred state.
Persist the projection with an atomic update
The example returns plain in-memory objects so that its behaviour is easy to inspect. It does not provide durable storage, transactions, or concurrent request handling.
In an application backend, recording that an event was processed and updating the corresponding order view need to succeed together. Otherwise, a crash can leave an event marked as handled without its state change, or apply a change again after a restart.
Use the transaction and concurrency controls appropriate to your database. Event records should have stable uniqueness rules, and the projection should be updated against the intended order version. A failed delivery acknowledgement may trigger another attempt, so a retry must remain safe under the provider's contract.
Also define retention and data access. Customer-facing state and event evidence serve different purposes; neither requires exposing private provider payloads to the mini app. Return only the fields the current customer is authorised to see.
Connect the view to the merchant mini app
After submitting a booking, the mini app can retrieve the relevant order view through an authorised backend endpoint. Depending on the application, updates might use polling, push, or another supported mechanism. The transport should not change which systems establish the underlying facts.
For a FinClip-based project, the runtime hosts the mini app within the wallet application. The customer or implementation partner builds the booking interface and backend connections. The actual merchant and payment services remain responsible for their business operations.
The client can present the current projection, show a useful reference, and offer the agreed support path. The backend decides what the current account can access and how authoritative events affect the order.
This arrangement also supports recovery after the customer closes and reopens the mini app. The view comes from the backend record, rather than from a success flag left on a device.
Read the assertions as a limited executable example
The assertions exercise payment-first and booking-first delivery, repeated events, a rejected appointment after payment, conflicting outcomes, incorrect orders, invalid sources, event-reference collisions, and unsupported events. They also check that the original state is not mutated.
Their passing result establishes the behaviour of this JavaScript projection under the supplied inputs. It does not verify a real merchant scheduling API, gateway signature implementation, database transaction, host integration, or complete payment lifecycle.
Use provider test environments and the merchant's actual booking system to examine those boundaries. Include a lost response, an expired appointment hold, and a late payment result. The customer's message should stay tied to the facts the systems can currently establish.
A wallet service can make booking feel straightforward while still representing the work accurately. Separate booking and payment states give the implementation a practical foundation for doing that.

Top comments (0)