Problem
The client is a college admissions consulting company. The goal was to digitize the whole process, from payment to finished consultation to follow-up. A consultation starts with a payment on the online store (Cafe24), but the time picked in the booking widget might not carry over into the internal consultation data after payment.
In peak season, many applicants had to be matched at once, so there was a high risk of booking a consultant twice for the same time.
Goals
- Pull store orders automatically and link them to students.
- Collect a different survey for each product, and manage consultant matching and schedules.
- Send KakaoTalk alert messages and texts automatically when a status changes.
- Split what each role can see: students, parents, consultants and admins.
How I got there
Feb to May 2026
Finding a way to link orders and bookings
At first, a person matched them by hand in an 'unmatched orders' tab. So students could log in right after paying, I cut order sync from every 10 minutes to 3, then to 1. Several options shared one product number, so some orders linked to the wrong product; I made matching check the product code and option name together, and in May switched to matching on student details and using the latest booking.
Feb to Aug 2026
Rewriting the slot guard more than once
At first it blocked only bookings with exactly the same start time, so a 60-minute consultation at 10:00 and another at 10:15 both went through. In late February I changed it to check for overlapping time with a row lock, and in July made confirmed conflicts impossible to force through. In August I wrote up a design that makes the DB the final judge, compared against 5 alternatives.
Apr 2026
Server migration, verification first
I wrote the verification scripts before the migration scripts. Then came the config, the runbook and the cutover scripts, and at the end of April I added a safeguard so the two servers' DBs could never be written at the same time.
How it works now
From payment to the end of a consultation and the notifications, in order.
- 1
Store orders are pulled every minute.
- 2
Booking sessions and orders are matched by phone number, product code and expiry time, then linked to the student.
- 3
Consultations follow a 9-stage state machine, and only allowed transitions happen.
- 4
A slot guard treats applications from paid through in progress as 'holding' the slot, and blocks double booking.
- 5
When a status changes, KakaoTalk alert messages and texts go out after the DB commit finishes.

Key decisions
Send texts only after the DB commit
- Why
- If a text goes out together with the status change, it's already sent even if the save rolls back.
- Alternatives
- Send right away inside a signal the moment the status changes
- Trade-off
- If the process dies right after the commit, a message can be lost, so I wrote a separate design doc for filling that gap.
No one can bypass a confirmed schedule conflict
- Why
- Confirmed conflicts are blocked, and only conflicts still at the preferred-time stage can be explicitly passed by an admin. For blocking it with a DB constraint, I compared 5 alternatives and wrote it up in a design doc.
- Alternatives
- Only warn about conflicts and leave it to the admin's judgment
- Trade-off
- Right now it's blocked in the application, so moving it to a DB constraint is left as the next step.
Hard problems
This includes problems I caught before release.
Someone else getting matched into a paid applicant's time slot
Another applicant could be matched into the time slot of an applicant who had paid but didn't have a consultant yet. I found it while looking into a production case where matching got stuck in peak season.
Why it was hard
Two opposite symptoms were mixed on the same matching screen. Duplicates that should have been blocked got through, and valid matches were blocked by locks on past time slots. The code that decides whether a slot is held was also scattered across several places.
How I traced it
1. What does the available-time logic count as holding a slot?
Only consultations in scheduled, in progress or completed status.
2. What does the booking side count as holding?
Only bookings awaiting payment or confirmed. The paid-but-not-yet-matched state wasn't counted as holding on either side.
3. Is it checked when a match is confirmed?
The confirm step had no conflict check at all.
4. How do I show why it got stuck in production?
I built a read-only diagnostic command that prints the blocking reason step by step, and found the cause without changing any data.
Root cause
The list of holding statuses was missing the statuses from paid through awaiting match, and confirming a match didn't check for conflicts.
- 1Books 10:00 and pays, not matched yet
- 210:00 shows as free
- 3Confirms B's match at 10:00
- 4Trying to match A hits a conflict at the same time
Fix
I defined paid through in progress as holding statuses. When a match is confirmed, it checks whether it overlaps, buffer time before and after included, with that consultant's holding consultations and live bookings. If it overlaps, it's rejected as 'time already booked'.
Before
confirm_matching(consultation, consultant, time):
save() # no conflict checkAfter
HOLDING = {PAID, SURVEY, AWAITING_MATCH, MATCHED, APPROVED, SCHEDULED, IN_PROGRESS}
conflict = any(other.consultant == consultant
and other.status in HOLDING
and overlaps_with_buffer(time, other)
and other != consultation)
or any(live booking overlaps, excluding own booking)
if conflict: raise "time already booked"Verification
- 9 regression tests (allows next day and same day, allows own booking, blocks on a held consultation, blocks on a held paid booking, allows when not overlapping, 3 for past time slots)
- Confirmed the cause with the read-only diagnostic command, without changing production data
Prevention
- Counting preferred times as holding too had a side effect: matching failed and KakaoTalk alert messages didn't go out. So I split it. Times still at the preferred stage can go ahead after an admin checks them, and no one can bypass an overlap with a confirmed schedule.
- With only an application check, a race remains between the check and the save. I wrote up blocking it with a DB time-range exclusion constraint in a design doc, compared against 5 alternatives.
Takeaway
An application that is paid but not yet assigned counts as holding its time slot.
Four ways booking info went wrong after payment
In the sync that links store payments to consultation bookings, I fixed 4 different bugs within 6 days. Payment times were off by 9 hours and consultations for canceled orders came back. Consultations were also created with another option's time and consultant, and changing a product's duration made it overlap existing bookings.
Why it was hard
The symptoms all looked alike ('the booking info is off'), but there were 4 causes. The time zone bug was especially hard: the value stayed the same and only its label changed to UTC, so the stored value alone didn't look wrong.
How I traced it
1. Why do products with a changed duration overlap?
It computed the held range using the old duration saved on the existing booking (for example, 30 minutes).
2. Why do consultations for canceled orders come back?
On re-sync, the 'has been paid' condition was true, and deleted consultations are hidden from the default query, so it decided 'the consultation is missing' and created it again.
3. Why does another option's time get in?
The first matching pass compared only the product code. The option filter was only in the second, fallback pass, so if the first pass picked a booking for another option, the option check never ran.
4. Why are payment times off by 9 hours?
After parsing a time with '+09:00', it only relabeled it as UTC. 09:22 Korea time became 09:22 UTC, which is 18:22 Korea time.
Root cause
The payment time parser didn't convert values that carried a time zone. It only relabeled them as UTC. This payment time is used for date filters, sorting and the payment date in notification text, so payments after 3 p.m. Korea time looked like next-day payments.
- 1PaymentSaves the payment time, creates a consultation
- 2CancelCancels the consultation, soft delete
- 3Next syncApplies the canceled status, payment time unchanged
- 4'Paid' and no consultation visible, so it creates one again
Fix
Only values without a time zone are labeled UTC. Values with a time zone are converted to UTC. Canceled orders never create a consultation, paid or not. Product and option filters apply at every matching pass, and the held range uses the longer of the saved duration and the product's current duration.
Before
return parse(s).replace(tz=UTC) # 09:22+09:00 -> 09:22Z (= 18:22 KST)After
p = parse(s)
return p.replace(tz=UTC) if is_naive(p) else p.astimezone(UTC)
effectively_paid = not cancelled and (now_paid or paid_at)
busy_minutes = max(saved_duration, current_product_duration)Verification
- One reproduction test per bug: payment time keeps the same instant, consultations for canceled orders aren't re-created, no matching with a booking for a different option, overlap blocked after a duration change
Prevention
- Specific conditions like options apply at every pass, including the fallback.
- For values that can change, like duration, it looks at both the saved value and the current value.
Takeaway
Values that carry a time zone get converted, not relabeled.
Results
- Backend tests
- 913 test functions
- Server migration scripts
- 12 scripts, 3,805 lines. Verification scripts written first
- Migration process
- DNS TTL lowered 48 hours ahead, then 7 days of verification after the cutover
- I finished the move from the previous server to AWS Lightsail.
- Payment, matching, consultations, reports and texts now connect inside one system.
