All work

Admissions Consulting CRM

A CRM for an admissions consulting company that handles payment, consultant assignment, consultations and follow-up in one system

Period
Feb to Sep 2026
Role
Contract work, from requirements to server migration (solo)
Commits
542

Stack Django 5.2, DRF, Celery, PostgreSQL, Next.js 16, Cafe24 API, Aligo SMS API, AWS Lightsail

CRM matching detail screen. It shows a consulting-confirmed badge, the product type, the assigned consultant, student info and a list of consulting follow-up materials.
Matching detail. Test data, with the client's logo hidden

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

  1. 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.

  2. 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.

  3. 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. 1

    Store orders are pulled every minute.

  2. 2

    Booking sessions and orders are matched by phone number, product code and expiry time, then linked to the student.

  3. 3

    Consultations follow a 9-stage state machine, and only allowed transitions happen.

  4. 4

    A slot guard treats applications from paid through in progress as 'holding' the slot, and blocks double booking.

  5. 5

    When a status changes, KakaoTalk alert messages and texts go out after the DB commit finishes.

CRM message automation screen. Rules for interview notices 1, 2 and 3 are listed with their trigger event, immediate sending and recipients.
Message automation. Admins set rules for which event sends a message to whom. The client's logo is hidden

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. 1. What does the available-time logic count as holding a slot?

    Only consultations in scheduled, in progress or completed status.

  2. 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. 3. Is it checked when a match is confirmed?

    The confirm step had no conflict check at all.

  4. 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.

Applicant AAdmin, applicant B
  1. 1Books 10:00 and pays, not matched yet
  2. 210:00 shows as free
  3. 3Confirms B's match at 10:00
  4. 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 check

After

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"
Client code, so this is pseudo-code with the same behavior

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. 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. 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. 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. 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.

Online storeCRM sync
  1. 1PaymentSaves the payment time, creates a consultation
  2. 2CancelCancels the consultation, soft delete
  3. 3Next syncApplies the canceled status, payment time unchanged
  4. 4'Paid' and no consultation visible, so it creates one again
The sequence that brought back consultations for canceled orders

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)
Client code, so this is pseudo-code with the same behavior

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.

Next case

CozySell

Order collection was automated, but buying and paying at the supplier was still done by hand, one order at a time.