איך נועלים חשבונית ברמת מסד הנתונים
הוראות ניהול הספרים קובעות שמסמך שהופק לא משתנה ולא נמחק. אפשר לכתוב את הכלל הזה בקוד האפליקציה ולקוות שאף אחד לא יעקוף אותו. אנחנו כתבנו אותו במקום שבו אי אפשר לעקוף: במנוע של מסד הנתונים.
הכלל, ואיפה לשים אותו
תוכנה שמנהלת ספרים בישראל מחזיקה שני סוגי רשומות. טיוטה היא ״קובץ זמני״: אפשר לערוך אותה ולמחוק אותה. מסמך שהופק שייך ל״קובץ הקבוע״: הוא לא משתנה, לא נמחק, ותיקון נעשה רק במסמך נוסף.
הדרך הרגילה לממש כלל כזה היא בשכבת השירות: פונקציית העדכון בודקת את הסטטוס ומסרבת. זה עובד עד היום שבו מישהו כותב סקריפט תחזוקה, מריץ מיגרציה שנוגעת בטבלה, או פותח קונסול כדי ״לתקן רק שורה אחת״. אף אחד מהם לא עובר דרך הפונקציה.
לכן הכלל אצלנו יושב בטריגר של PostgreSQL. שכבת השירות עדיין לא חושפת שום נתיב לעדכון מסמך שהופק, אבל היא כבר לא קו ההגנה היחיד.
הטריגר: משווים את כל השורה
זה לב הפונקציה שרצה לפני כל UPDATE ו-DELETE על טבלת המסמכים (מקוצר, בלי הודעות השגיאה המלאות):
CREATE OR REPLACE FUNCTION bk_protect_document() RETURNS trigger AS $$
DECLARE
exempt text[] := ARRAY['originPrintedAt','cancelledById','pdfOriginFileId',
'pdfCopyFileId','copyCount','updatedAt','allocationNumber'];
BEGIN
IF TG_OP = 'DELETE' THEN
IF OLD."status" <> 'draft' THEN
RAISE EXCEPTION 'issued document % cannot be deleted', OLD."id";
END IF;
RETURN OLD;
END IF;
IF OLD."status" = 'draft' THEN
RETURN NEW; -- the issue transition happens while the row is still a draft
END IF;
IF (to_jsonb(NEW) - exempt) IS DISTINCT FROM (to_jsonb(OLD) - exempt) THEN
RAISE EXCEPTION 'issued document % is immutable', OLD."id";
END IF;
IF OLD."originPrintedAt" IS NOT NULL
AND NEW."originPrintedAt" IS DISTINCT FROM OLD."originPrintedAt" THEN
RAISE EXCEPTION 'originPrintedAt is set-once';
END IF;
IF NEW."copyCount" < OLD."copyCount" THEN
RAISE EXCEPTION 'copyCount may only increase';
END IF;
-- the same set-once check for cancelledById, the PDF ids and allocationNumber
RETURN NEW;
END $$ LANGUAGE plpgsql;שלוש החלטות בקוד הזה שוות הסבר.
טיוטה חופשית, וההפקה עוברת בזכות זה
הטריגר מסתכל על המצב הישן של השורה. כל עוד היא טיוטה, הכול מותר. גם המעבר עצמו לסטטוס ״הופק״ הוא עדכון של שורה שהייתה טיוטה, ולכן הוא עובר בלי מקרה מיוחד. מהעדכון הבא והלאה השורה כבר נעולה.
רשימת חריגים, לא רשימת עמודות נעולות
הגרסה הנאיבית בודקת עמודה אחרי עמודה: הסכום לא השתנה, התאריך לא השתנה. היא נשברת בשקט ביום שמוסיפים עמודה ושוכחים להוסיף אותה לטריגר. אנחנו הופכים את השורה כולה ל-JSON, מורידים רשימה קצרה של עמודות, ומשווים את השאר. עמודה חדשה נעולה מהרגע שנוצרה. כדי לפתוח אותה צריך מיגרציה שמוסיפה אותה לרשימה, ומיגרציה כזאת עוברת סקירה.
גם החריגים לא חופשיים
לכל עמודה ברשימה יש כלל משלה. תאריך מסירת המקור נכתב פעם אחת, כי מקור נמסר פעם אחת וכל הדפסה אחריו היא העתק. מונה ההעתקים רק עולה. הקישור לחשבונית הזיכוי שביטלה את המסמך נכתב פעם אחת. מספר ההקצאה נכתב פעם אחת: הוא מגיע מרשות המסים לפעמים רגע אחרי ההפקה, ולכן נוסף לרשימה במיגרציה נפרדת, עם הכלל שמספר שנכתב לא משתנה.
השורות, התקבולים והטבלה כולה
מסמך הוא לא שורה אחת. שורות הפירוט והתקבולים יושבים בטבלאות משלהם, וטריגר נפרד עליהם בודק את הסטטוס של מסמך האב: אם האב כבר הופק, אי אפשר להוסיף, לשנות או למחוק שורה שלו. אותו רעיון מגן על הוצאות שנרשמו, על קובצי ה-PDF החתומים ועל יומן הביקורת, שאפשר רק להוסיף לו.
ויש עוד דלת אחת שקל לשכוח. TRUNCATE לא מפעיל טריגרים של שורה, ולכן על כל טבלה של הקובץ הקבוע יש טריגר ברמת הפקודה שמסרב לו.
מספור בלי חורים
מספור של מסמכי מס חייב להיות רציף. הכלי הטבעי ב-PostgreSQL למספרים עולים הוא sequence, והוא לא מתאים: ערך שנמשך מ-sequence לא חוזר כשהטרנזקציה נכשלת. כישלון אחד בזמן ההפקה, וברצף נשאר חור.
במקום זה לכל עסק ולכל סדרה יש שורת מונה, והיא מתעדכנת עם אותו חיבור שרושם את המסמך:
export async function allocateNumber(tx, businessId, scope) {
const { lastNumber } = await tx.bkCounter.update({
where: { businessId_scope: { businessId, scope } },
data: { lastNumber: { increment: 1 } },
select: { lastNumber: true },
})
return lastNumber
}נעילת השורה מסדרת בתור שתי הפקות שרצות באותו רגע על אותה סדרה. אם הטרנזקציה נכשלת, המונה חוזר יחד איתה. שורות המונה נוצרות כשהעסק נפתח, כך שההקצאה היא תמיד עדכון פשוט ואין מרוץ על יצירת השורה. בדיקה אוטומטית מפיקה מסמכים במקביל ומוודאת שהרצף שלם.
מה למדנו בדרך
כלי ה-ORM לא יודע שיש טריגרים
כשהעברנו את הספר למסד נתונים משלו, ביקשנו מהכלי לייצר את המיגרציה ההתחלתית מהסכמה. קיבלנו 14 טבלאות ואפס טריגרים. הסכמה של ה-ORM מתארת טבלאות ועמודות, והטריגרים חיים רק ב-SQL שכתבנו ביד. מסד הנתונים החדש היה עולה בלי שום נעילה, וכל הבדיקות של הלוגיקה העסקית היו עוברות.
מה שתפס את זה הוא בדיקה שלא סומכת על הקוד: היא מנסה לעדכן, למחוק ולרוקן מסמך שהופק, ומצפה שמסד הנתונים יסרב. מאז היא רצה מול כל מסד נתונים חדש לפני שהוא מקבל נתונים.
העתקה דרך ה-ORM אינה העתקה נאמנה
בחזרה הגנרלית של אותה העברה העתקנו את השורות דרך ה-ORM. ספירת השורות יצאה זהה. השוואה לפי תוכן גילתה שב-32 מתוך 486 שורות, ערך NULL של SQL בעמודת JSON הפך לערך null של JSON. זה הבדל קטן, אבל בטבלה שהשורות בה נעולות הוא היה נשאר לתמיד. ההעברה האמיתית נעשתה ב-pg_dump, והאימות לפי תקציר של התוכן, לא לפי ספירה.
מה שקופא, קופא עם כל מה שבתוכו
בזמן ההפקה נשמר צילום מלא של המסמך, כדי שהדפסה בעוד חמש שנים תיראה בדיוק כמו היום. בדיקה של המסמכים הראשונים הראתה שצילום חציוני שוקל 287KB, כשהנתונים של המסמך עצמו הם פחות מ-1KB. כל השאר היה הלוגו של העסק, שנשמר מחדש בכל מסמך.
את הצילומים שכבר נשמרו אי אפשר לתקן, וזה בדיוק העניין. מסמכים חדשים שומרים הפניה ללוגו יחד עם תקציר SHA-256 שלו, והקובץ עצמו נשמר פעם אחת לעסק. לפני כל הדפסה התקציר מחושב מחדש, ואם הוא לא תואם, המסמך מודפס בלי לוגו ולא עם לוגו אחר.
מה טריגר לא פותר
טריגר מגן מפני טעויות ומפני קוד. הוא לא מגן מפני מי שיכול למחוק את הטריגר עצמו. לכן הנעילה היא שכבה אחת מכמה:
- חתימה דיגיטלית. ה-PDF נחתם בזמן ההפקה, ושינוי של בייט אחד בקובץ שובר את החתימה. את תעודת החותם אפשר להוריד ולאמת מולה.
- יומן ביקורת להוספה בלבד. כל הפקה, הדפסה וייצוא נרשמים בטבלה שגם היא נעולה לעדכון ולמחיקה.
- דוח רצף. סריקה של כל סדרה שמראה כל מספר שקיים וכל מספר שחסר.
- גיבוי וארכיון נפרד. עותק של הספר שנשמר מחוץ למסד הנתונים הראשי.
כל המנגנונים האלה עומדים מאחורי כל קריאה ל-API של BillOS. מי שמפיק דרכו חשבונית מקבל אותם בלי לכתוב אותם.
שאלות נפוצות
למה לא לאכוף את הכלל בקוד האפליקציה?
כי קוד האפליקציה הוא רק אחת הדרכים להגיע למסד הנתונים. סקריפט תחזוקה, מיגרציה, קונסול של מסד הנתונים או קוד שייכתב בעוד שנה לא עוברים דרך אותה בדיקה. טריגר במנוע חל על כולם.
איך מתקנים מסמך שנעול?
לא מתקנים אותו. מפיקים חשבונית זיכוי שמפנה אליו, והמסמך המקורי נשאר כמו שהיה. כך דורשות הוראות ניהול הספרים, וכך עובד ה-API.
למה לא להשתמש ב-sequence של PostgreSQL למספור?
כי sequence לא חוזר אחורה. טרנזקציה שנכשלה אחרי שקיבלה מספר משאירה חור ברצף, ומספור של מסמכי מס חייב להיות רציף. שורת מונה שמתעדכנת בתוך הטרנזקציה חוזרת יחד איתה.
האם טריגר מגן גם מפני מי שיש לו גישת מנהל למסד הנתונים?
לא. מי שיכול למחוק טריגר יכול לעקוף אותו. לכן יש שכבות נוספות: ה-PDF חתום דיגיטלית כך ששינוי של בייט אחד שובר את החתימה, יומן הביקורת הוא להוספה בלבד, ויש גיבויים ודוח רצף.
המסמך הראשון, עוד היום
מפתח Sandbox בחינם, בלי כרטיס אשראי. הקוד שעובד מול הבדיקות עובד מול live בלי שינוי.
מפתח Sandbox בחינם
