پرش به محتویات

مدل داده

یک حساب، چند هویت

User (uuid)
  ├── Identity(telegram, "555001")
  ├── Identity(bale, "900123")
  └── Identity(web)

شناسهٔ پیام‌رسان هرگز خودِ حساب نیست. اشاره‌گری به یکی است.

همین تمایز دلیل آن است که آدم می‌تواند حساب تلگرامش را از دست بدهد، به بله برود، یا شمارهٔ تلفنش را عوض کند بی‌آنکه دفترهایش با آن‌ها بروند. راه دیگر — استفاده از شناسهٔ تلگرام به‌عنوان کلید اصلی — تا همان روزی که دیگر کار نمی‌کند کار می‌کند، و بعدش جبران‌ناپذیر است.

قید یکتای (provider, external_id) جهت دیگر را تضمین می‌کند: یک حساب تلگرام نمی‌تواند دو مجموعه دفتر را تغذیه کند.

اتصال

دو جریان، هر دو از راه یک LinkToken یک‌بارمصرف که خلاصه‌اش ذخیره می‌شود، نه خودِ توکن.

جهت چه کسی شروع می‌کند طرف دیگر چه می‌کند
FROM_WEB حسابی که وارد شده درخواست پیوند می‌دهد پیام‌رسان پیوند عمیقی را که حاملش است باز می‌کند
FROM_MESSENGER پیام‌رسان ناشناخته /start می‌گوید پنل وب کدی را که نشانش داده شده مطالبه می‌کند

هر دو در ۱۵ دقیقه منقضی می‌شوند: به‌قدری بلند که بشود بین برنامه‌ها جابه‌جا شد و چسباند، و به‌قدری کوتاه که پیوند درزکرده پیش از پیدا شدنش مرده باشد.

توکنی که برای تلگرام صادر شده اگر از بله مصرف شود رد می‌شود.

دفترها

Book
  ├── type: personal | business | team | organization
  ├── base_currency
  ├── owner_user_id
  └── Membership[]  →  User, Role

نوع، برچسب نیست. Scope پیش‌فرض تراکنش را تعیین می‌کند، و اینکه اصلاً حقوق و دستمزد وجود داشته باشد یا نه — تقسیم سود بین یک نفر امکان نیست.

نقش‌ها و دسترسی‌ها

Role به مجموعه‌ای از Permission نگاشت می‌شود. نقش‌ها درشت‌اند چون آدم‌ها می‌فهمندشان؛ دسترسی‌ها ریزند چون کد لازمشان دارد.

نقش تقریباً
OWNER همه‌چیز، از جمله قفل‌کردن دوره و واگذاری دفتر
ADMIN همه‌چیز جز قفل‌کردن دوره
MANAGER ثبت‌ها، گزارش‌ها، بودجه، حقوق
MEMBER کار خودش را ثبت می‌کند، گزارش‌ها را می‌بیند
VIEWER می‌خواند

هر فراخوان سرویس از یک دروازه رد می‌شود:

await self.books.require(book_id, user_id, Permission.RECORD_EXPENSE)

وقتی فراخوان اصلاً عضو نیست NotFound می‌دهد — نه PermissionDenied. به غیرعضو گفته می‌شود دفتر وجود ندارد، پس شناسهٔ دفترها را نمی‌شود با تماشای اینکه کدام خطا برمی‌گردد کاوید.

تراکنش‌ها

Transaction
  flow             income | expense
  scope            personal | work | team
  category         متن آزاد، ۸۰ کاراکتر
  original_amount / original_currency / conversion_rate / converted_amount
  occurred_on      یک تاریخ، در انبار میلادی
  receipt_file_id  شناسهٔ خود سرویس‌دهنده، اگر عکسی پیوست شده باشد
  → JournalEntry   متوازن، همیشه

scope هست تا دفتر تیمی تصادفاً خرید خانگی کسی را ثبت نکند. از نوع دفتر مقدار پیش‌فرض می‌گیرد و می‌شود بازنویسی‌اش کرد.

رسیدها شناسهٔ فایل سرویس‌دهنده را ذخیره می‌کنند، هرگز خود بایت‌ها را. نمایششان همان شناسه را پس می‌فرستد. چیزی دانلود نمی‌شود، چیزی ذخیره نمی‌شود، و سطلی از عکس‌های مردم نیست که لازم باشد امنش کنیم.

کنار شناسه، receipt_kind و receipt_file_name و receipt_mime_type هستند، چون شناسه مبهم است و هیچ‌کدام را نمی‌گوید. بدونشان فاکتور PDF و عکس رول صندوق یک سطر یکسان‌اند: صفحه فقط می‌تواند بگوید «یکی هست»، و پس‌فرستادنش باید sendPhoto را امتحان کند و ردش را بخورد تا به sendDocument برسد. هر سه تهی‌پذیرند — هر رسیدی که پیش از وجودشان پیوست شده خالی داردشان، و کد برای آن‌ها به حدس‌زدن برمی‌گردد.

ارزها و کیف پول

یک دفتر می‌تواند بیش از یک ارز نگه دارد. BookCurrency فهرست ارزهایی است که اجازهٔ نگهداری‌شان را دارد؛ ارز پایه هیچ‌وقت ردیفی در آن ندارد، پس همیشه مجاز است و نمی‌شود خاموشش کرد.

BookCurrency          book_id, code, is_active
CurrencyConversion    from_currency/from_amount  →  to_currency/to_amount
                      base_value, base_currency   ارزش این تبدیل در دفتر

موجودی‌ها محاسبه می‌شوند، ذخیره نمی‌شوند. آنچه دفتر از یک ارز دارد، درآمد منهای هزینهٔ همان ارز است، به‌اضافه و منهای آنچه تبدیل‌ها جابه‌جا کرده‌اند. موجودیِ ذخیره‌شده نسخهٔ دومی از عددی است که تراکنش‌ها همین حالا دارند، و این دو اولین باری که ردیفی ویرایش شود از هم جدا می‌افتند.

هر چه وارد شود در همان ارز می‌ماند. ده تتر دریافتی یعنی ده تتر موجودی؛ عدد تومانی روی تراکنش منجمد می‌شود به‌عنوان ارزش‌گذاری، نه به‌عنوان موجودی.

هزینه به ارزی که دفتر به‌اندازهٔ کافی ندارد رد می‌شود. ارز پایه مستثناست: موجودی‌اش اینجا درآمد منهای هزینه است نه ماندهٔ بانکی — هیچ‌جا ماندهٔ افتتاحیه یا واریز از بیرون ثبت نمی‌شود — و اعمال این قاعده اولین هزینهٔ هر دفتر تازه را رد می‌کرد.

تبدیل ارز عمداً تراکنش نیست. حقوق و سهم از جمع درآمد و هزینه حساب می‌شود، پس تبدیلی که هر دو ثبت شود پیه را باد می‌کند و سهم هر عضو را جابه‌جا می‌کند، بابت معامله‌ای که هیچ درآمدی برای دفتر نداشته.

تبدیل‌ها سند حسابداری نمی‌زنند. دفتر روزنامه یک دفتر درآمد و هزینه به ارز پایه است و پرداخت حقوق و سهم خزانه هم در آن ثبت نمی‌شوند؛ تبدیل نه درآمد است نه هزینه، پس تراز آزمایشی با آن تکان نمی‌خورد.

پرداخت به عضو نوع سوم حرکت پول است، کنار درآمد/هزینه و تبدیل ارز. از کیف پول خارج می‌شود ولی هزینهٔ کسب‌وکار نیست: سهم‌ها از جمع درآمد و هزینه حساب می‌شوند، پس شمردن پرداخت به‌عنوان هزینه، پرداخت‌های دورهٔ قبل را از پیهٔ این دوره کم می‌کرد. ردیف Payment خودِ سند است و کیف پول از رویش می‌خواند.

نرخ لحظه‌ای

kasbbook.rates.swapwallet اندپوینت عمومی بازار سواپ‌ولت را می‌خواند که کلید نمی‌خواهد. ربات هنگام ثبت مبلغ ارزی نرخ را خودش پر می‌کند و نشانش می‌دهد، با دکمه‌ای برای تغییر — معاملهٔ OTC معمولاً نرخ بازار نیست.

عمداً بیرون از modules/ است: هر سرویس اینجا فقط با دیتابیس حرف می‌زند، و قاعده‌ای که به در دسترس بودن یک سرویس بیرونی وابسته باشد بدترین وقت شکست می‌خورد. منبع نرخ تزریق می‌شود؛ سرویسی که نداشته باشد نرخ ندارد و از خود شخص می‌پرسد. قطع بودن فید هیچ‌وقت خطا نمی‌دهد.

فقط قیمت خوانده می‌شود. همان API می‌تواند سواپ انجام دهد و برداشت کند، و هیچ چیزی اینجا به آن دسترسی ندارد.

نرخ ثبت‌شده هیچ‌وقت دوباره ارزش‌گذاری نمی‌شود. هر تراکنش نرخ لحظهٔ ثبتش را برای همیشه نگه می‌دارد، تا گزارش ماه قبل با تکان بازار عوض نشود. آنچه دوباره ارزش‌گذاری می‌شود موجودی است: کیف پول می‌گوید نرخ امروز می‌گوید این توکن‌ها چقدر می‌ارزند — که سؤال دیگری است از اینکه وقتی رسیدند چقدر می‌ارزیدند.

برنامه‌ریزی

مدل چه نگه می‌دارد
Budget یک سقف، برای هر دسته یا هر جهت، و آنچه در برابرش خرج شده
Debt چه کسی، چقدر، کدام طرف، سررسید کی — تسویه تراکنش واقعی می‌نویسد
Loan مبلغ و تعداد قسط؛ پرداخت‌ها سطرهای LoanPaymentاند که هرکدام به یک تراکنش اشاره می‌کنند
RecurringRule یک تعریف به‌علاوهٔ next_run_on که با هر شلیک جلو می‌رود

قاعدهٔ تکرارشونده‌ای که عقب افتاده باشد خودش را می‌رساند، با سقف ۴۰۰ شلیک — قاعده‌ای که به هر دلیل سال‌ها عقب افتاده نباید یک بعدازظهر را صرف نوشتن تراکنش کند.

حقوق و دستمزد

FinancialPeriod   یک بازه، با یک وضعیت
  ├── Adjustment[]   پاداش و کسر، علامت‌دار، جداگانه تأییدشونده
  ├── Payslip[]      یکی برای هر عضو، با تمام ورودی‌ها منجمد رویش
  │     └── Payment[]   مرحله‌ای: سهم اغلب در چند انتقال پرداخت می‌شود
  └── TreasuryAllocation[]   آنچه صندوق‌ها برداشتند، یک بار نوشته‌شده

وضعیت‌ها روی مسیری ثابت حرکت می‌کنند (PERIOD_TRANSITIONS)؛ هر چیز دیگری ایراد است، نه تصمیم. دورهٔ قفل‌شده ویرایش را رد می‌کند — اصلاح‌ها در دورهٔ بعدی می‌روند، که حسابداری همین‌طور کار می‌کند.

فیش حقوقی از کل قابل‌توزیع، مبنای سهم و مقدار سهم عکس می‌گیرد. بازخوانی حقوق سال گذشته آنچه را که آن موقع تصمیم گرفته شده نشان می‌دهد، نه آنچه قواعد امروز می‌گویند.

حقوق و دستمزد را ببین.

اعتبارنامه‌ها

مدل ذخیره‌شده ذخیره‌نشده
User.password_hash Argon2id رمز
LinkToken.token_digest SHA-256 کد
RefreshToken.token_digest SHA-256، به‌علاوهٔ شناسهٔ خانواده توکن
ApiKey.token_digest SHA-256، به‌علاوهٔ پیشوند ۸ کاراکتری کلید

پیشوند آشکار نگه داشته می‌شود تا بشود کلید را در فهرست نام برد و باطلش کرد بی‌آنکه صاحبش مجبور باشد ارائه‌اش کند.

حساب می‌تواند با هیچ‌کدام شروع شود

create_account_from_messenger حسابی می‌سازد بدون ایمیل، بدون تلفن و بدون رمز — چون کسی که در تلگرام «برایم حساب بساز» را می‌زند چیز دیگری به ما نداده. آن حساب کار می‌کند، اما بن‌بست است: نمی‌تواند وارد API شود، همکار نمی‌تواند پیدایش کند تا به دفتری اضافه‌اش کند، و از دست‌دادن پیام‌رسان یعنی از دست‌دادن دفترها — دقیقاً همان خرابی‌ای که مدل هویت برای جلوگیری از آن هست.

پس ربات همین را روی صفحه‌ای که حساب را اعلام می‌کند می‌گوید، و پنل حساب تا وقتی ایمیل یا تلفنی تعیین نشده مدام می‌گویدش. set_contact و set_password راه بیرون‌آمدن‌اند، و هر دو از ربات و API در دسترس‌اند.

امنیت را ببین.

ممیزی

AuditEvent فقط افزودنی است و هر چیزی را که تغییر می‌دهد چه کسی چه می‌بیند ثبت می‌کند: ساخت حساب، اتصال و قطع هویت، افزودن و برداشتن عضو، جلو بردن دوره، صدور و ابطال کلید API، و توکن تازه‌سازی‌ای که استفادهٔ مجددش تشخیص داده شده.

حذف

بستن حساب بسته به اینکه چه چیزی به آن وابسته است دو عملیات متفاوت است.

دفترهایی که کس دیگری در آن‌ها نیست یکسره نابود می‌شوند — تراکنش‌ها، دفتر روزنامه، بودجه، بدهی، وام، حقوق، خزانه. هر کلید خارجی به books روی ON DELETE CASCADE است، پس دیتابیس تقریباً همه‌اش را خودش انجام می‌دهد؛ دو یال RESTRICT (journal_lines → accounts و treasury_allocations → treasury_funds) را اول BookService.delete_book پاک می‌کند، چون دیتابیس قول نمی‌دهد آبشار را به ترتیبی پردازش کند که راضی‌شان کند.

دفترهایی که با دیگران مشترک‌اند کل کار را متوقف می‌کنند. مال یک نفر نیستند که نابودشان کند، پس عملیات رد می‌شود و نامشان را می‌برد؛ اول با transfer_ownership واگذارشان کن.

خودِ سطر حساب فقط اگر مجبور باشد زنده می‌ماند. سه کلید خارجی RESTRICT‌اند — transactions.actor_user_id، adjustments.recorded_by، recurring_rules.created_by_user_id — و هرکدام دفتر است که می‌گوید سند مالی نباید نویسنده‌اش را از دست بدهد. وقتی سطرهایی در دفتر دیگران نام این شخص را می‌برند، سطر به‌جای حذف بی‌نام می‌شود: بدون نام، بدون ایمیل، بدون تلفن، بدون رمز، بدون هویت، is_active نادرست، و token_generation جلو رفته تا هیچ چیزِ در حال پرواز کار نکند. وقتی هیچ چیزی به آن اشاره نمی‌کند، سطر یکسره حذف می‌شود.

هر دو مسیر پیام‌رسان را آزاد می‌کنند، پس کسی که حسابش را می‌بندد و برمی‌گردد واقعاً از نو شروع می‌کند.