معماری بک‌اند

موزر از درون: نوبتی که پولش بعد از تأیید آرایشگر گرفته می‌شود

عمر یک نوبت در موزر از درخواست تا لغو: چرا پرداخت منتظر تأیید آرایشگر می‌ماند، پول در هر مرحله کجای کیف پول است، و قفل ردیف و ارجاع یکتا چطور بازپرداخت را دقیق و یک‌باره نگه می‌دارند.

Muzer, Django and PWA

موزر (muzer.ir) سامانه‌ی نوبت‌گیری از آرایشگرهای مردانه است که خودم ساخته‌ام. مشتری شهرش را انتخاب می‌کند، آرایشگرهای آن شهر را با نمونه‌کار، امتیاز و نزدیک‌ترین وقت آزاد می‌بیند و برای یک خدمت و ساعت درخواست می‌فرستد. آرایشگر از «میز» خودش ساعت‌های کاری، خدمت‌ها، درخواست‌ها، تقویم و کیف پولش را اداره می‌کند. زیر کار یک پروژه‌ی Django روی قالب پایه‌ی خودم، docker-core، است با PostgreSQL و Redis و Celery. اینجا فقط سراغ قلب کار می‌روم: عمر یک نوبت، و این‌که پول در هر لحظه کجاست.

چرا پرداخت منتظر تأیید می‌ماند

رزرو در موزر سه قدم دارد: درخواست، تأیید آرایشگر، پرداخت. عمداً پول را همان اول نمی‌گیرم:

  • درخواست وقت را نگه نمی‌دارد. چند مشتری می‌توانند یک ساعت را بخواهند و آرایشگر یکی را انتخاب می‌کند؛ گرفتن پول از همه یعنی پس دادن پول همه به‌جز یکی.
  • شرایط مالی هنگام تأیید ثابت می‌شود. آرایشگر می‌تواند قیمت نهایی را تغییر دهد، و نوع همکاری (کمیسیون یا اشتراک) و درصد کمیسیون همان لحظه روی سفارش ذخیره می‌شود.
  • «نه» هزینه ندارد. اگر آرایشگر رد کند یا تا 24 ساعت جواب ندهد، پولی جابه‌جا نشده که برگردد. توضیح خود سایت هم همین را می‌گوید: تا تأیید آرایشگر پولی نپردازید.

هزینه‌اش ناهمزمان شدن رزرو است، و برای همین پوش برای مشتری اولویت گرفت. آرایشگری که این رفت‌وبرگشت را نمی‌خواهد «رزرو فوری» (Barber.auto_accept) را روشن می‌کند و درخواست همان لحظه با قیمت فهرست تأیید می‌شود.

ماشین حالت سفارش

Order این وضعیت‌ها را دارد: request، awaiting_payment، reserved، completed، rejected، expired و سه نوع لغو (مشتری، آرایشگر، سایت). تنها کدی که وضعیت یا پول سفارش را عوض می‌کند order_service.py است: هر تابعش در transaction.atomic اجرا می‌شود، اول ردیف سفارش را با select_for_update قفل می‌کند و اگر تغییر با وضعیت فعلی جور نباشد OrderError می‌دهد. مهم‌ترین گذار تأیید است (کوتاه‌شده):

@transaction.atomic
def approve(order, *, final_price=None, instant=False):
    order = _lock(order)
    if not order.barber_can_answer():
        raise OrderError(...)
    _check_still_free(order)
    # ...
    if barber.get_cooperation_mode(at) == Order.CooperationChoices.SUBSCRIPTION:
        order.commission_percent = Decimal("0")
        order.amount_payable = system_money("booking_fee")
    else:
        order.commission_percent = barber.get_commission_percent(at)
        order.amount_payable = price
    order.status = STATUS.AWAITING_PAYMENT
    minutes = system_number("awaiting_payment_expiry_minutes")
    order.datetime_expires = min(at + timedelta(minutes=minutes), order.start_at)
    order.save()
    reject_requests_overlapping(barber, order.start_at, order.end_at, exclude=order.pk)

از این لحظه نوبت وقتش را نگه می‌دارد، درخواست‌های هم‌پوشان همان آرایشگر در همان تراکنش رد می‌شوند و مشتری 30 دقیقه، یا تا شروع نوبت اگر زودتر برسد، فرصت پرداخت دارد. بعد از آن task انقضا وقت را آزاد می‌کند.

زمان‌های آزاد

برنامه‌ی هفتگی آرایشگر یا ساده است یا برای هر روز چند بازه دارد. schedule_service.py بازه‌ها را روی شبکه‌ای با گام slot_step می‌چیند و شروعی را نگه می‌دارد که کل خدمت در آن جا شود و با زمان اشغال تداخل نداشته باشد. زمان اشغال را هر اپ با register_busy_source ثبت می‌کند: مرخصی‌ها، سفارش‌هایی که وقت نگه می‌دارند و مشتری‌های حضوری. درخواست با همان آزمون، check_slot، سنجیده می‌شود، پس صفحه و سرور هیچ‌وقت درباره‌ی آزاد بودن یک وقت اختلاف ندارند.

کیف پول: سه سطل و یک گلوگاه

کیف پول از docker-core می‌آید و سه سطل دارد: balance (پول تسویه‌شده)، locked (بخشی از آن که برای پرداختی در جریان کنار رفته) و pending (پولی که هنوز قطعی نشده). هیچ کدی این ستون‌ها را مستقیم عوض نمی‌کند؛ همه از Wallet._move می‌گذرند:

@transaction.atomic
def _move(self, *, d_balance=0, d_locked=0, d_pending=0):
    wallet = Wallet.objects.select_for_update().get(pk=self.pk)
    new_balance = wallet.balance + d_balance
    new_locked = wallet.locked + d_locked
    new_pending = wallet.pending + d_pending
    if new_balance < 0 or new_locked < 0 or new_pending < 0:
        raise ValueError(...)
    if new_locked > new_balance:
        raise ValueError(...)
    # ... the three columns are saved together

روی آن، wallet_service.py واژگان دامنه است و هر فراخوانی دقیقاً یک WalletTransaction می‌سازد:

فراخوانیاثر
debitپرداخت کامل از کیف پول
reserve_paymentسهم کیف پول، تا مشتری در بانک است (locked)
capture_paymentپرداخت درگاه موفق شد
release_paymentپرداخت ناموفق؛ locked آزاد می‌شود
hold_earningسهم آرایشگر بعد از رزرو (pending)
settle_earningانجام شد: از pending به balance
cancel_earningلغو شد: pending باطل می‌شود
creditبازپرداخت یا درآمد (balance)

کلید سازگاری transaction_reference است که یکتاست. هر حرکت ارجاعی قطعی از کد سفارش دارد، مثل order-{code}-refund؛ اگر همان فراخوانی دوباره برسد، مثلاً task تکراری یا callback درگاهی که با reverify مسابقه می‌دهد، ردیف قبلی پیدا می‌شود و کاری تکرار نمی‌شود.

پول در هر مرحله کجاست

درخواست و تأیید پولی جابه‌جا نمی‌کنند. در پرداخت اول کیف پول مشتری خرج می‌شود و باقی از درگاه. سهم آرایشگر تا «انجام شد» (به دست او، یا خودکار 24 ساعت بعد از پایان نوبت) در pending می‌ماند، و سهم سایت در هیچ کیف پولی نیست؛ عددی روی سفارش است (platform_share). در مدل کمیسیون مشتری کل قیمت را آنلاین می‌دهد و سایت درصدی برمی‌دارد (پیش‌فرض 10 درصد، و صفر در شش ماه اول آرایشگر)؛ در مدل اشتراک فقط هزینه‌ی رزرو آنلاین است و خدمت سر صندلی پرداخت می‌شود. پرداخت نقدی استثناست و کمیسیونش بدهی آرایشگر می‌شود.

لغو و بازپرداخت

وقتی آرایشگر یا سایت لغو می‌کند تقصیر با مشتری نیست، پس همه‌ی پول به کیف پولش برمی‌گردد:

paid = order.amount_paid
if order.is_reserved() and not order.is_cash() and (paid > 0 or order.promo_discount > 0):
    wallet_service.cancel_earning(f"order-{order.code}-earning", reason="booking canceled")
    if by == "customer":
        refund, barber_part = customer_cancel_split(
            paid, order.promo_discount, refund_percent_for(order.hours_left())
        )
    else:
        refund, barber_part = paid, Decimal("0")
    if refund > 0:
        wallet_service.credit(
            order.customer, refund,
            type_=_txn_types().REFUND,
            reference=f"order-{order.code}-refund",
        )

همه‌ی این‌ها یک تراکنش روی ردیف قفل‌شده‌ی سفارش است؛ اگر یک قدم خطا بدهد، هیچ‌کدام نمی‌ماند. دو لغو هم‌زمان، یا لغوی هم‌زمان با «انجام شد» خودکار، پشت هم اجرا می‌شوند و دومی وضعیت تازه را می‌بیند و OrderError می‌گیرد. اعلان و پیامک با transaction.on_commit فرستاده می‌شوند، پس تغییری که rollback شود به کسی خبر داده نمی‌شود.

لغو به دست مشتری از جدول RefundRule پیروی می‌کند که ادمین ویرایشش می‌کند. پیش‌فرض: با دست‌کم 24 ساعت فاصله 100 درصد برمی‌گردد، 12 ساعت 75، 6 ساعت 50، یک ساعت 25 و کمتر از آن هیچ؛ باقی‌مانده نصف‌نصف بین آرایشگر و سایت تقسیم می‌شود. در تستش نوبتی 200 هزار تومانی سه ساعت مانده به شروع لغو می‌شود: 50 هزار تومان به مشتری برمی‌گردد و 150 هزار باقی 75 و 75 تقسیم می‌شود. صفحه‌ی لغو همین عدد را پیش از تأیید نشان می‌دهد.

بازپرداخت همیشه اول به کیف پول می‌رود؛ اگر مشتری کارتش را بخواهد، بلافاصله یک برداشت از مسیر عادی تسویه ثبت می‌شود. یک راه برای برگشت پول، نه دو راه. «بستن یک روز» در میز آرایشگر هم برای هر نوبت آن روز همین cancel را از طرف آرایشگر صدا می‌زند.

پولی که دیر می‌رسد

درگاه‌ها با پکیج خودم، django-iranian-payment، وصل‌اند. مشتری ممکن است در صفحه‌ی بانک بماند و نوبت در این فاصله منقضی یا لغو شود. settle_online_payment تلاش پرداخت را قفل می‌کند و موفقیت را فقط یک بار اعمال می‌کند (is_applied). بعد اگر سفارش هنوز منتظر پرداخت باشد رزرو می‌شود؛ وگرنه کل مبلغ به کیف پول مشتری برمی‌گردد. پول گم نمی‌شود.

اتوماسیون و اشتراک

Celery beat نوبت‌های پرداخت‌نشده را هر 5 دقیقه منقضی و درخواست‌های بی‌جواب را هر 10 دقیقه رد می‌کند؛ هر task امن است که دو بار هم‌زمان اجرا شود، چون ردیفش را قفل می‌کند و وضعیت را دوباره می‌سنجد. آرایشگر هم می‌تواند به‌جای کمیسیون اشتراک بخرد (SubscriptionPlan). نوع همکاری روی آرایشگر ذخیره نمی‌شود، از تاریخ‌ها مشتق و هنگام تأیید روی سفارش ثابت می‌شود؛ پس خرید یا پایان اشتراک نوبت تأییدشده را دوباره قیمت‌گذاری نمی‌کند.

پوش و PWA

هر رویداد یک UserNotification می‌سازد و یک سیگنال post_save همان را بعد از commit و در Celery به دستگاه‌های آن شخص پوش می‌کند؛ کد سفارش از پوش خبر ندارد. Web Push را بدون کتابخانه‌ی پوش و از روی استانداردهای RFC 8030 و RFC 8291 و RFC 8292 نوشتم. endpoint را مرورگر می‌دهد و هر کسی می‌تواند آن را بفرستد، پس فقط به میزبان‌های شناخته‌شده‌ی سرویس‌های پوش درخواست می‌رود.

دو اپ قابل نصب داریم که هر دو همین سایت Django هستند: اپ آرایشگر که روی میز باز می‌شود، و اپ مشتری که بیشتر برای اعلان‌هاست، چون آیفون فقط به سایت نصب‌شده پوش می‌دهد. هر کدام service worker و اشتراک پوش خودش را دارد و نسخه‌ی اندرویدشان Trusted Web Activity است.

آنچه نگه می‌دارم

یک جا برای تغییر وضعیت، یک گلوگاه برای حرکت پول و یک ارجاع یکتا برای هر حرکت. درگاه، Celery و پوش روی همین سه سوار می‌شوند و هر کدام می‌توانند دوباره اجرا شوند بی‌آنکه حساب کسی به هم بریزد.