الكتابات/tutorial/2026/08
Tutorial21 أغسطس 2026·26 دقيقة

الإشعارات الدائنة والفوترة الذاتية في PINT AE الإماراتي بلغة TypeScript

ابنِ طبقة التصحيحات للفوترة الإلكترونية الإماراتية بلغة TypeScript: الإشعار الدائن الضريبي (381) في PINT AE، قاعدة منع الفواتير السالبة، مرجع الفاتورة السابقة واستثناء خصم الحجم، ضريبة القيمة المضافة للإشعارات الجزئية بالفلس الصحيح، سجل الحماية من تجاوز الدائن، وملف الفوترة الذاتية المنفصل.

يُحكم على تطبيقات الفوترة الإلكترونية مرتين. عند الإطلاق، يُحكم عليها بمولّد الفواتير — هل تستطيع إنتاج مستند PINT AE صالح وتمريره عبر مزوّد الخدمة المعتمد (ASP). وفي الإنتاج، يُحكم عليها بالتصحيحات — والتصحيحات هي حيث ستفشل التطبيقات الإماراتية فعلاً، لأن أول إرجاع من عميل، وأول خصم حجم ربع سنوي، وأول سطر بسعر خاطئ، كلها تصل خلال أسابيع من الإطلاق، وكل واحدة منها تتطلب مستنداً ليس هو الذي بنيته.

بموجب التفويض الإماراتي، التصحيح ليس ملف PDF مجاملة. يجب إصدار الإشعار الدائن الضريبي كمستند مهيكل، يُرسل عبر مزوّد الخدمة المعتمد كأي فاتورة، ويُربط بالمستند الذي يصحّحه، ويصدر خلال 14 يوماً من واقعة التعديل. الطرح المعلن مرحلي — يناير 2027 للمنشآت الأكبر، ويوليو 2027 لبقية المسجّلين في ضريبة القيمة المضافة، وأكتوبر 2027 للجهات الحكومية — مع التحفظ المعتاد بأن التواريخ تترسّخ عبر إعلانات وزارة المالية والهيئة الاتحادية للضرائب، فتأكد من الجدول الزمني الحالي مع مزوّدك المعتمد لا من مقال مدونة، بما في ذلك هذا المقال.

يبني هذا الدليل طبقة التصحيحات بلغة TypeScript. وهو الرفيق التنفيذي الذي أجّله صراحةً دليل فاتورة PINT AE: ذلك الجزء يغطي بناء الفاتورة الضريبية 380 والتحقق منها — حساب الفلس، وحدة تثبيت المواصفة، وSchematron المحلي في CI — ولا شيء منه يتكرر هنا. أما هذا فيغطي كل ما يجعل الإشعار الدائن كائناً مختلفاً: نوع مستند مختلف بأسماء عناصر مختلفة، رابط إلزامي بالفاتورة السابقة، رمز سبب له استثناء واحد بالضبط، حارس ضد تجاوز الدائن سيسألك عنه مدققك، وملف فوترة ذاتية يصفه الجميع تقريباً وصفاً خاطئاً.

كل حقيقة مواصفات أدناه قُرئت من مواصفات PINT AE المنشورة (إصدار 2025-Q2 وقت الكتابة) على docs.peppol.eu. مزوّدك المعتمد يعتمد على إصدار محدد؛ فاعتبر ملاحظات إصداره الحكَم الفاصل.

المتطلبات المسبقة

قبل البدء، تأكد من توفر:

  • Node.js 20+ و TypeScript 5+ مع تفعيل strict وnoUncheckedIndexedAccess وexactOptionalPropertyTypes — كل مقتطف هنا يُجمَّع تحت هذه الأعلام
  • دليل فاتورة PINT AE — يفترض هذا المقال نوع النقود الصحيح Fils وانضباط تثبيت المواصفة منه
  • فهم عملي لبنية عناصر UBL (لا يلزم حفظها؛ الفروق المهمة مجدولة أدناه)

ما الذي ستبنيه

محرك تصحيحات من ستة أجزاء:

  1. وحدة مواصفة مثبَّتة تحمل المعرّفات ورموز الأنواع التي قد يخالفك فيها مزوّدك المعتمد
  2. اتحاد مميَّز (discriminated union) يجعل الإشعار الدائن بلا مرجع غير قابل للتمثيل أصلاً
  3. موزّع تناسبي يحوّل "أعد قيد وحدة واحدة من الوحدات الثلاث في السطر 1" إلى فلوس موجبة صحيحة
  4. إعادة حساب لضريبة القيمة المضافة تُطابق الفاتورة الأصلية بدلاً من الانحراف بفلس
  5. سجل تصحيحات يجعل تجاوز الدائن خطأً يُرمى، وإعادة المعالجة عملية لا أثر لتكرارها
  6. مُسلسِل (serializer) يستطيع إصدار كلا الترميزين المنشورين للإشعار الدائن على السلك، لأن أيّهما يتوقعه مزوّدك المعتمد حقيقة إعدادات، لا حقيقة كونية

الخطوة 1: ثبّت المواصفة — ولاحظ ما ليس فيها

لطبقة التصحيحات مجموعة ثوابتها الخاصة، وأحدها يصحّح خرافة انتشرت بالفعل في أسئلة الموردين الشائعة.

// src/spec/pint-ae.ts — every constant your ASP can disagree with lives here.
export const PINT_AE_RELEASE = '2025-Q2';
 
export const CUSTOMIZATION_ID = {
  billing: 'urn:peppol:pint:billing-1@ae-1',
  self_billing: 'urn:peppol:pint:selfbilling-1@ae-1',
} as const;
 
export const PROFILE_ID = {
  billing: 'urn:peppol:bis:billing',
  self_billing: 'urn:peppol:bis:selfbilling',
} as const;
 
// UAE document type codes. Note what is NOT here: 389 and 361.
export const DOC_TYPE = {
  taxInvoice: '380',
  taxCreditNote: '381',
  outOfScopeInvoice: '480',
  outOfScopeCreditNote: '81',
} as const;
 
export const CREDIT_NOTE_ISSUANCE_DAYS = 14;

أربعة رموز أنواع مستندات تغطي النموذج الإماراتي كله: 380 للفاتورة الضريبية، و381 للإشعار الدائن الضريبي، و480 للفاتورة خارج نطاق الضريبة، و81 للإشعار الدائن المتعلق بسلع أو خدمات خارج النطاق.

خرافة 389/361. تذكر عدة شروحات إماراتية للفوترة الإلكترونية أن الفواتير ذاتية الإصدار تستخدم رمز النوع 389 والإشعارات الدائنة ذاتية الإصدار تستخدم 361. مواصفة الفوترة الذاتية المنشورة في PINT AE لا تفعل ذلك — بل تستخدم رموز الأنواع الأربعة نفسها المستخدمة في ملف الفوترة العادي. تُشار الفوترة الذاتية بـ معرّف المواصفة (urn:peppol:pint:selfbilling-1@ae-1) وملف الفوترة الذاتية، لا برمز نوع خاص. الالتباس مستورد من Peppol BIS الأوروبي حيث يوجد 389 و261 كرموز ذاتية الإصدار. ابنِ الافتراض الأوروبي في نظام إماراتي وستحمل مستنداتك "ذاتية الإصدار" رمز نوع لا تعترف به قواعد التحقق الإماراتية.

الخطوة 2: نمذجة المستندات الأربعة — واجعل المرجع الغائب مستحيلاً

أكثر أسباب رفض الإشعارات الدائنة شيوعاً هو مرجع مفقود أو مشوّه للفاتورة السابقة. يشترط PINT AE مرجع الفاتورة السابقة على الإشعار الدائن — إلا حين يكون الدائن خصمَ حجم، وعندئذ يُضبط رمز السبب (المصطلح الإماراتي الخاص BTAE-03) على VD ويجوز حذف المرجع، لأن خصماً ربع سنوي لا يصحّح فاتورة واحدة بعينها.

قاعدة "إلزامي، إلا" هذه هي بالضبط ما وُجدت الاتحادات المميَّزة لأجله. نمذج السبب بحيث يفرض المُجمِّع الاستثناء:

// src/documents.ts
import type { Fils } from './money';
 
export type VatCategory = 'S' | 'Z' | 'E' | 'AE' | 'O';
 
export interface DocumentLine {
  id: string;
  itemName: string;
  quantity: number;
  /** Line net amount in integer fils. Always positive on the wire. */
  netAmount: Fils;
  vatCategory: VatCategory;
  /** Percentage, e.g. 5 for the UAE standard rate. */
  vatRate: number;
}
 
export interface PrecedingInvoiceRef {
  invoiceNumber: string;
  issueDate: string; // YYYY-MM-DD
}
 
/**
 * BTAE-03 drives this union. Volume discounts ('VD') are the one reason
 * that waives the preceding invoice reference — every other reason
 * cannot be constructed without one.
 */
export type CreditReason =
  | { kind: 'volume_discount' }
  | { kind: 'return'; preceding: PrecedingInvoiceRef }
  | { kind: 'post_invoice_adjustment'; preceding: PrecedingInvoiceRef }
  | { kind: 'invoice_error'; preceding: PrecedingInvoiceRef };
 
export type PintAeDocument =
  | { docType: '380'; kind: 'tax_invoice'; id: string; issueDate: string; lines: DocumentLine[] }
  | { docType: '381'; kind: 'tax_credit_note'; id: string; issueDate: string; reason: CreditReason; lines: DocumentLine[] }
  | { docType: '480'; kind: 'out_of_scope_invoice'; id: string; issueDate: string; lines: DocumentLine[] }
  | { docType: '81'; kind: 'out_of_scope_credit_note'; id: string; issueDate: string; reason: CreditReason; lines: DocumentLine[] };
 
export type BillingProfile = 'billing' | 'self_billing';

tax_credit_note بلا reason لا يُجمَّع. وreturn بلا مرجع preceding لا يُجمَّع. أما الحالة الوحيدة المشروعة بلا مرجع — خصم الحجم — فهي متغيّر مقصود ومرئي، لا حقل اختياري ينسى أحدهم تعبئته. حين تصل رسالة رفض الهيئة عبر ثلاث طبقات وساطة من مزوّدك المعتمد، تكون عبارة "المُجمِّع لم يكن ليسمح لي ببناء ذلك المستند" نقطة انطلاق للتصحيح أفضل بكثير من "الحقل اختياري في نموذجنا".

الخطوة 3: قاعدة منع الفواتير السالبة

يسمح PINT العام — النموذج الدولي — بطريقتين لعكس فاتورة: إصدار إشعار دائن، أو إصدار فاتورة سالبة. أما الربط الإماراتي فيلغي الخيار. تنص المواصفة بوضوح: في الإمارات، عكس فاتورة صدرت واستُلمت لا يتحقق إلا بإصدار إشعار دائن.

لهذا نتيجة بنيوية على نموذجك الداخلي. أنظمة المحاسبة تعشق الأرقام الموقَّعة — الإرجاع صفٌّ سالب، وجمع العمود يعطي المركز الصافي. احتفظ بذلك، داخلياً. لكن مستند السلك مختلف: الإشعار الدائن في PINT AE يحمل مبالغ موجبة، ونوع المستند هو ما يحمل الاتجاه. التحويل بين دفترك الموقَّع والسلك غير الموقَّع مكانه موضع واحد بالضبط — الحافة — وأي تقريب لقيمة وسيطة كسرية يجب أن يقرّب المقدار ثم يعيد تطبيق الإشارة، وإلا فسيُضيّع سلوك Math.round عند حد الـ .5 فلساً في العمليات العكسية:

// src/money.ts
declare const filsBrand: unique symbol;
export type Fils = number & { readonly [filsBrand]: true };
 
export function fils(n: number): Fils {
  if (!Number.isSafeInteger(n)) {
    throw new Error(`amounts are integer fils; got ${n}`);
  }
  return n as Fils;
}
 
/** Round a fractional fils value: round the magnitude, then reapply the sign. */
export function roundFils(value: number): Fils {
  const sign = value < 0 ? -1 : 1;
  return fils(sign * Math.round(Math.abs(value)));
}

إن وصل مبلغ سالب يوماً إلى مُسلسِلك، فهذه ليست مشكلة تنسيق تُغطّى بـ Math.abs — بل خلل في المنبع (عادةً إرجاع عولج كفاتورة سالبة في ERP مضبوط لولاية قضائية أخرى)، وعلى المُسلسِل أن يرمي خطأً بدلاً من غسله.

الخطوة 4: ساعة الأربعة عشر يوماً

يجب إصدار الإشعار الدائن خلال 14 يوماً من واقعة التعديل. وهي مدة قصيرة بما يكفي لتجعل "المالية تكنس الإرجاعات أسبوعياً والـ ERP يُجمِّع الإشعارات شهرياً" — وهي عملية طبيعية تماماً قبل التفويض — غير ممتثلة بنيوياً. تحتاج الموعد النهائي قيمةً محسوبة ومُراقَبة، لا معرفة قبلية متوارثة:

// src/deadline.ts
import { CREDIT_NOTE_ISSUANCE_DAYS } from './spec/pint-ae';
 
function assertIsoDate(date: string): void {
  const parsed = new Date(`${date}T00:00:00.000Z`);
  if (parsed.toISOString().slice(0, 10) !== date) {
    throw new Error(`not a real calendar date: ${date}`);
  }
}
 
/** Last day a credit note may be issued for an adjustment event. */
export function creditNoteDeadline(adjustmentDate: string): string {
  assertIsoDate(adjustmentDate);
  const d = new Date(`${adjustmentDate}T00:00:00.000Z`);
  d.setUTCDate(d.getUTCDate() + CREDIT_NOTE_ISSUANCE_DAYS);
  return d.toISOString().slice(0, 10);
}
 
export function daysRemaining(adjustmentDate: string, asOf: string): number {
  assertIsoDate(asOf);
  const deadline = new Date(`${creditNoteDeadline(adjustmentDate)}T00:00:00.000Z`);
  const now = new Date(`${asOf}T00:00:00.000Z`);
  return Math.floor((deadline.getTime() - now.getTime()) / 86_400_000);
}

تفصيلتان تحملان الثقل هنا. أولاً، assertIsoDate موجودة لأن Date في JavaScript لا ترفض التواريخ المستحيلة — new Date('2027-02-30T00:00:00.000Z') تتدحرج بصمت إلى الثاني من مارس، وتاريخ تعديل متدحرج يزيح موعداً قانونياً بصمت. فحص الذهاب والإياب وحده يلتقط ذلك. ثانياً، asOf مُعامل مُمرَّر، وليس new Date() داخل الدالة أبداً — القاعدة نفسها التي يطبّقها دليل مطابقة التسويات على المطابقة، وللسبب نفسه: تقرير مواعيد لا تستطيع إعادة تشغيله عن الثلاثاء الماضي هو تقرير مواعيد لا تستطيع تصحيحه.

اربط daysRemaining بأي نظام تنبيهات تشغّله أصلاً، ونبّه عند يومين متبقيين، لا عند الخرق. التنبيه الذي ينطلق بعد فوات الموعد ليس تنبيهاً، بل تقرير حادثة.

الخطوة 5: الإشعارات الجزئية — التوزيع والضريبة التي يجب أن تُطابِق

العكس الكامل للفاتورة هو الحالة السهلة. الحالة الشائعة جزئية: أعد قيد وحدة من 3، أو سطراً من عشرة، أو تخفيض سعر بنسبة 10%. قاعدتان تُبقيان الإشعارات الجزئية أمينة.

القاعدة الأولى: الدائن الكامل ينسخ، والجزئي يحسب. إذا ساوت الكمية المطلوبة الكمية المفوترة، انسخ المبلغ الأصلي بالضبط — تمرير الأصل × 1.0 عبر الفاصلة العائمة لتعود من حيث بدأت دعوةٌ لفرق فلس واحد على نوع المستند الوحيد الذي تُدقَّق فروقه.

// src/allocation.ts
import type { DocumentLine } from './documents';
import { fils, roundFils } from './money';
 
export interface CreditRequest {
  lineId: string;
  /** Quantity being credited; must not exceed the original quantity. */
  quantity: number;
}
 
/**
 * Build credit-note lines from original invoice lines, pro-rata by quantity.
 * Amounts stay positive — the document type carries the direction.
 */
export function allocateCredit(
  originalLines: readonly DocumentLine[],
  requests: readonly CreditRequest[],
): DocumentLine[] {
  const byId = new Map(originalLines.map((l) => [l.id, l]));
  return requests.map((request) => {
    const original = byId.get(request.lineId);
    if (!original) {
      throw new Error(`no such line on the original invoice: ${request.lineId}`);
    }
    if (request.quantity <= 0 || request.quantity > original.quantity) {
      throw new Error(
        `credited quantity ${request.quantity} out of range for line ${request.lineId} (invoiced ${original.quantity})`,
      );
    }
    const ratio = request.quantity / original.quantity;
    return {
      ...original,
      quantity: request.quantity,
      netAmount: request.quantity === original.quantity
        ? fils(original.netAmount) // full credit: copy exactly, no arithmetic
        : roundFils(original.netAmount * ratio),
    };
  });
}

القاعدة الثانية: تُحسب ضريبة القيمة المضافة مرة واحدة لكل مجموعة فئة على الأساس المجموع — على الإشعار الدائن تماماً كما على الفاتورة. المثال المحسوب في دليل الفاتورة ينطبق هنا بقوة أكبر: ثلاثة أسطر بقيمة 33.33 درهماً بنسبة 5% تعطي 5.01 دراهم إن قرّبت لكل سطر ثم جمعت، و5.00 دراهم إن جمعت الأساس وقرّبت مرة واحدة. على الفاتورة، ذلك الفلس مسألة اتساق في Schematron. وعلى الإشعار الدائن أسوأ: دائن كامل لا تساوي ضريبته ضريبة الفاتورة الأصلية يترك مركز ضريبة شبحياً بفلس واحد على معاملة لم تعد موجودة، ويقبع في إقرارك الضريبي حتى يفسّره أحدهم.

// src/vat.ts
import type { DocumentLine } from './documents';
import { Fils, fils, roundFils } from './money';
 
/**
 * VAT is computed once per category group on the summed base —
 * never per line and then summed. Same rule as on the invoice side.
 */
export function vatTotals(lines: DocumentLine[]): Map<string, Fils> {
  const bases = new Map<string, { base: number; rate: number }>();
  for (const line of lines) {
    const key = `${line.vatCategory}:${line.vatRate}`;
    const group = bases.get(key) ?? { base: 0, rate: line.vatRate };
    group.base += line.netAmount;
    bases.set(key, group);
  }
  const totals = new Map<string, Fils>();
  for (const [key, group] of bases) {
    totals.set(key, roundFils((group.base * group.rate) / 100));
  }
  return totals;
}
 
export function documentVat(lines: DocumentLine[]): Fils {
  let sum = fils(0);
  for (const amount of vatTotals(lines).values()) {
    sum = fils(sum + amount);
  }
  return sum;
}

الخطوة 6: حارس تجاوز الدائن

لا شيء في مخطط XML يمنعك من إعادة قيد 12,000 درهم على فاتورة بـ 10,000 درهم — عبر ثلاثة إشعارات دائنة منفصلة، كل واحد منها معقول بمفرده. يتحقق Schematron من مستند واحد في كل مرة؛ أما تجاوز الدائن فهو ثابت عابر للمستندات، فلا يمكن أن يعيش إلا في نظامك، على هيئة سجل:

// src/ledger.ts
import type { DocumentLine } from './documents';
import { Fils, fils } from './money';
 
export class OverCreditError extends Error {
  constructor(lineId: string, attempted: number, available: number) {
    super(
      `over-credit on line ${lineId}: attempted ${attempted} fils, only ${available} fils remain creditable`,
    );
    this.name = 'OverCreditError';
  }
}
 
interface LinePosition {
  invoiced: Fils;
  credited: Fils;
}
 
/**
 * Tracks, per original invoice line, how much has already been credited.
 * Claims are recorded per credit-note id so reprocessing the same
 * credit note is idempotent rather than double-counted.
 */
export class CorrectionLedger {
  private readonly positions = new Map<string, LinePosition>();
  private readonly applied = new Set<string>();
 
  registerInvoice(invoiceId: string, lines: readonly DocumentLine[]): void {
    for (const line of lines) {
      this.positions.set(`${invoiceId}:${line.id}`, {
        invoiced: line.netAmount,
        credited: fils(0),
      });
    }
  }
 
  claim(creditNoteId: string, invoiceId: string, lines: readonly DocumentLine[]): void {
    if (this.applied.has(creditNoteId)) return; // idempotent replay
    // Validate everything before mutating anything.
    for (const line of lines) {
      const position = this.positions.get(`${invoiceId}:${line.id}`);
      if (!position) {
        throw new Error(`credit references unknown line ${line.id} on ${invoiceId}`);
      }
      const available = position.invoiced - position.credited;
      if (line.netAmount > available) {
        throw new OverCreditError(line.id, line.netAmount, available);
      }
    }
    for (const line of lines) {
      const key = `${invoiceId}:${line.id}`;
      const position = this.positions.get(key);
      if (!position) continue;
      this.positions.set(key, {
        invoiced: position.invoiced,
        credited: fils(position.credited + line.netAmount),
      });
    }
    this.applied.add(creditNoteId);
  }
 
  remaining(invoiceId: string, lineId: string): Fils {
    const position = this.positions.get(`${invoiceId}:${lineId}`);
    if (!position) throw new Error(`unknown line ${lineId} on ${invoiceId}`);
    return fils(position.invoiced - position.credited);
  }
}

ثلاثة قرارات تصميمية أهم من بنية البيانات نفسها. المطالبات مفهرسة بمعرّف الإشعار الدائن، فرسالة يعيد الطابور تشغيلها أو webhook يعيد مزوّدك إرساله عمليةٌ بلا أثر، لا خصم مزدوج. يكتمل التحقق قبل أن يبدأ أي تعديل، فالإشعار الدائن الذي يتجاوز في سطره الثاني لا يترك سطره الأول نصف مُطبَّق. والحارس يرمي خطأً — تجاوز الدائن ليس أبداً تحذيراً يُسجَّل في اللوج، لأن مَن سيقرأ اللوج هو نفسه مَن أخطأ للتو في كمية الإرجاع. في الإنتاج يغلّف هذا الصنف جدول قاعدة بيانات بالعمودين نفسيهما؛ والنسخة في الذاكرة موجودة ليكون الثابت قابلاً للاختبار في CI.

الخطوة 7: نموذج دلالي واحد، ترميزان على السلك

هذه هي الحقيقة التي وُجد هذا الدليل لإزالة غموضها: ينشر PINT AE ربط بنية (syntax binding) لكلا ترميزي الإشعار الدائن. هناك مستند ubl:Invoice يحمل cbc:InvoiceTypeCode بقيمة 381 — ومثال خصم الحجم في المواصفة نفسها مرمَّز بهذه الطريقة — وهناك مستند ubl:CreditNote كامل بشجرة بنيته الخاصة، يحمل cbc:CreditNoteTypeCode بقيمة 381. يعبّران عن الدلالات نفسها بأسماء عناصر مختلفة:

الدلالةترميز Invoiceترميز CreditNote
العنصر الجذرInvoiceCreditNote
عنصر رمز النوعcbc:InvoiceTypeCodecbc:CreditNoteTypeCode
حاوية الأسطرcac:InvoiceLinecac:CreditNoteLine
الكميةcbc:InvoicedQuantitycbc:CreditedQuantity
الفاتورة السابقةcac:BillingReferencecac:BillingReference

أيّهما يسافر على السلك يقرّره إصدار مزوّدك المعتمد ووثائق تهيئته — إنها حقيقة إعدادات عن تكاملك، لا شيء يُدفن ثابتاً في مئة موضع استدعاء. لذا يستقبل المُسلسِل الربط مُعاملاً صريحاً، وبقية النظام لا تذكر أسماء العناصر أبداً:

// src/serialize.ts
import type { CreditReason, DocumentLine, PintAeDocument, BillingProfile } from './documents';
import { CUSTOMIZATION_ID, PROFILE_ID } from './spec/pint-ae';
 
/**
 * PINT AE publishes syntax bindings for BOTH encodings of a credit note.
 * Your ASP's certified release decides which one travels.
 * Pin it in configuration; never hardcode it at call sites.
 */
export type WireBinding = 'invoice-381' | 'creditnote-root';
 
const esc = (s: string) =>
  s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
 
function precedingRefXml(reason: CreditReason): string {
  if (reason.kind === 'volume_discount') return '';
  return [
    '  <cac:BillingReference>',
    '    <cac:InvoiceDocumentReference>',
    `      <cbc:ID>${esc(reason.preceding.invoiceNumber)}</cbc:ID>`,
    `      <cbc:IssueDate>${reason.preceding.issueDate}</cbc:IssueDate>`,
    '    </cac:InvoiceDocumentReference>',
    '  </cac:BillingReference>',
  ].join('\n');
}
 
function lineXml(line: DocumentLine, binding: WireBinding): string {
  const lineEl = binding === 'creditnote-root' ? 'cac:CreditNoteLine' : 'cac:InvoiceLine';
  const qtyEl = binding === 'creditnote-root' ? 'cbc:CreditedQuantity' : 'cbc:InvoicedQuantity';
  return [
    `  <${lineEl}>`,
    `    <cbc:ID>${esc(line.id)}</cbc:ID>`,
    `    <${qtyEl}>${line.quantity}</${qtyEl}>`,
    `    <cbc:LineExtensionAmount currencyID="AED">${(line.netAmount / 100).toFixed(2)}</cbc:LineExtensionAmount>`,
    `  </${lineEl}>`,
  ].join('\n');
}
 
export function serializeCreditNote(
  doc: Extract<PintAeDocument, { kind: 'tax_credit_note' | 'out_of_scope_credit_note' }>,
  binding: WireBinding,
  profile: BillingProfile,
): string {
  const root = binding === 'creditnote-root' ? 'CreditNote' : 'Invoice';
  const typeCodeEl =
    binding === 'creditnote-root' ? 'cbc:CreditNoteTypeCode' : 'cbc:InvoiceTypeCode';
  return [
    `<${root}>`,
    `  <cbc:CustomizationID>${CUSTOMIZATION_ID[profile]}</cbc:CustomizationID>`,
    `  <cbc:ProfileID>${PROFILE_ID[profile]}</cbc:ProfileID>`,
    `  <cbc:ID>${esc(doc.id)}</cbc:ID>`,
    `  <cbc:IssueDate>${doc.issueDate}</cbc:IssueDate>`,
    `  <${typeCodeEl}>${doc.docType}</${typeCodeEl}>`,
    precedingRefXml(doc.reason),
    ...doc.lines.map((line) => lineXml(line, binding)),
    `</${root}>`,
  ].filter(Boolean).join('\n');
}

يعرض المقتطف الهيكل — المعرّفات، رمز النوع، المرجع السابق، الأسطر — لأنها الأجزاء التي تختلف بين الترميزين. أما مجموعة حقول PINT AE الكاملة (كتل الأطراف، مصطلحات الامتداد الإماراتي BTAE مثل مبلغ الضريبة بالدرهم والمبلغ المستحق، مجاميع الضريبة، مجاميع المستند) فهي بالضبط البنّاء الذي جمّعته في دليل الفاتورة؛ الإشعار الدائن يحمل الكتل نفسها، وانضباطك الحالي في ترتيب العناصر ينطبق دون تغيير، لأن XSD الخاص بـ UBL يفرض ترتيب العناصر على مستندات CreditNote بالصرامة نفسها التي يفرضها على مستندات Invoice.

اسأل مزوّدك المعتمد سؤالاً واحداً كتابةً قبل بناء هذه الخطوة: "بالنسبة للإشعارات الدائنة الضريبية، هل يتوقع إصداركم المعتمد من PINT AE بنية Invoice برمز النوع 381، أم بنية CreditNote؟" إنه جواب من سطر واحد يوفّر سباق إعادة تسلسل كاملاً، ووجوده كتابةً يحسم الجدل حين يظهر رفضٌ بعد ستة أشهر.

الخطوة 8: الفوترة الذاتية ملفٌّ (profile) لا رمز نوع

الفوترة الذاتية — أن يصدر العميل الفاتورة ويرسلها إلى المورد، وهي نمط معتاد للأسواق الإلكترونية وترتيبات الأمانة وتسويات العمولات — لها مواصفة PINT AE خاصة بها. ثلاث حقائق تبقيها مستقيمة:

  1. المعرّفات تتغيّر. معرّف التخصيص urn:peppol:pint:selfbilling-1@ae-1، والملف urn:peppol:bis:selfbilling. هذا هو كامل ما يشير إلى أن المستند ذاتي الإصدار.
  2. رموز الأنواع لا تتغيّر. الفاتورة ذاتية الإصدار تبقى 380؛ والإشعار الدائن ذاتي الإصدار يبقى 381 (مع 480 و81 لخارج النطاق). لا 389 ولا 361 — راجع الخطوة 1.
  3. أدوار الأطراف لا تتبادل. المورد — الطرف الذي يقوم بالتوريد الخاضع — يبقى في كتلة المورد، والمشتري يبقى في كتلة المشتري، رغم أن المشتري هو مَن حرّر المستند. إعادة الهيكلة "الذكية" التي تبادل كتل الأطراف لأن "المشتري هو المُصدِر" تنتج مستنداً يدّعي أن المشتري ورّد بضاعة لنفسه؛ وهي الخطوة الخاطئة الأكثر إغراءً في أي تطبيق فوترة ذاتية.

في هذه البنية، تكلّف الفوترة الذاتية مُعاملاً واحداً. serializeCreditNote(doc, binding, 'self_billing') يبدّل المعرّفات، وكل ما عداه — اتحاد الأسباب، التوزيع، السجل، ساعة الموعد — يبقى مطابقاً، وهذا بالضبط ما يبرّر جعل الملف قيمةً لا مساراً برمجياً ثانياً.

ملاحظة حدود واحدة: هل يجوز لك أصلاً الفوترة الذاتية في علاقة مورد معيّنة سؤالٌ قانوني — لترتيبات الفوترة الذاتية شروط اتفاق وأهلية بموجب قواعد الضريبة الإماراتية تعيش خارج صيغة المستند. المواصفة لا ترمّز شيئاً من ذلك. احصل على تأكيد الترتيب من مستشارك الضريبي قبل أن يغادر أول مستند ذاتي الإصدار نظامك؛ صلاحية XML لا تثبت شيئاً عن جواز الترتيب.

اختبار تطبيقك

كل مقتطف في هذا الدليل استُخرج إلى مشروع وتُحقّق منه قبل النشر: يمرّ tsc --noEmit بلا أي أخطاء تحت strict وnoUncheckedIndexedAccess وexactOptionalPropertyTypes، وتعمل حزمة التوكيدات أدناه بنجاح. هذه هي التوكيدات التي تلتقط الانحدارات الحقيقية:

// test.ts (excerpts — the assertions that matter)
import assert from 'node:assert/strict';
 
// The 33.33 case: per-line rounding drifts, summed-base rounding ties.
const thirds = [1, 2, 3].map((i) => ({
  id: String(i), itemName: 'x', quantity: 1,
  netAmount: fils(3333), vatCategory: 'S' as const, vatRate: 5,
}));
assert.equal(documentVat(thirds), 500);  // AED 5.00 — correct
assert.equal(
  thirds.map((l) => Math.round(l.netAmount * 0.05)).reduce((a, b) => a + b, 0),
  501,                                    // AED 5.01 — the drift you must not ship
);
 
// Over-credit throws, and a replayed credit note is a no-op.
const ledger = new CorrectionLedger();
ledger.registerInvoice('INV-100', lines);
ledger.claim('CN-1', 'INV-100', allocateCredit(lines, [{ lineId: '1', quantity: 2 }]));
ledger.claim('CN-1', 'INV-100', allocateCredit(lines, [{ lineId: '1', quantity: 2 }]));
assert.equal(ledger.remaining('INV-100', '1'), 3333); // counted once
assert.throws(
  () => ledger.claim('CN-2', 'INV-100', allocateCredit(lines, [{ lineId: '1', quantity: 2 }])),
  OverCreditError,
);
assert.equal(ledger.remaining('INV-100', '1'), 3333); // failed claim applied nothing
 
// Impossible dates must not roll over into wrong legal deadlines.
assert.equal(creditNoteDeadline('2027-01-20'), '2027-02-03');
assert.throws(() => creditNoteDeadline('2027-02-30'));
 
// Both wire bindings carry 381; volume discounts omit the reference.
assert.match(serializeCreditNote(cn, 'invoice-381', 'billing'), /<cbc:InvoiceTypeCode>381</);
assert.match(serializeCreditNote(cn, 'creditnote-root', 'billing'), /<cbc:CreditNoteTypeCode>381</);
assert.doesNotMatch(serializeCreditNote(vd, 'invoice-381', 'billing'), /BillingReference/);

التوكيدان اللذان يليان رمي خطأ التجاوز هما اللذان تتجاوزهما الفرق عادةً: كون إعادة التشغيل بلا أثر (مزوّدك المعتمد سوف يعيد إرسال callback يوماً ما)، وكون المطالبة الفاشلة تترك السجل دون مساس (التطبيق الجزئي هو الطريقة التي يفسد بها إشعار دائن مرفوض رصيدَ المتبقي القابل للقيد بصمت).

استكشاف الأخطاء وإصلاحها

رُفض إشعارك الدائن لغياب مرجع الفاتورة السابقة. السبب أي شيء غير خصم الحجم، وكتلة BillingReference غائبة أو رقم فاتورتها لا يطابق مستنداً مُرسَلاً. إن سمح لك نموذجك ببناء ذلك المستند، فشدّد النموذج — هذا هو اتحاد الخطوة 2 يؤدي عمله.

يمرّ إشعارك الدائن محلياً لكن مزوّدك المعتمد يرفض بنية المستند رفضاً مباشراً. أنت شبه مؤكد تُصدر الربط الخاطئ — جذر CreditNote إلى نقطة نهاية معتمدة على Invoice برمز 381، أو العكس. هذا يفشل قبل تقييم أي قاعدة عمل، فتكون رسالة الخطأ عادةً رسالة مخطط غير مفيدة. افحص الربط أولاً، لا محتويات الحقول.

دائن كامل يترك بقية ضريبة بفلس واحد على الفاتورة الأصلية. تقريب ضريبة لكل سطر في مكان ما من الخط — عادةً تصدير ERP يحسب ضريبة السطر قبل أن يعمل كودك أصلاً. أعد حساب الضريبة لكل مجموعة فئة على الأساس المجموع وقت التسلسل وعامل الأرقام الواردة لكل سطر كقيم عرض.

يظهر إشعار دائن مرتين في سجلك بعد إعادة محاولة من المزوّد. المطالبات مفهرسة بشيء غير فريد (طابع زمني، معرّف صف) بدلاً من معرّف مستند الإشعار الدائن. يجب أن تُفهرس اللاتكرارية بالمعرّف التجاري.

تُرفض المستندات ذاتية الإصدار تحت ملف الفوترة العادي. المستند يحمل urn:peppol:pint:billing-1@ae-1 بدلالات ذاتية الإصدار، أو تبادلت كتل الأطراف. أعد قراءة الخطوة 8؛ أرسل تحت معرّف تخصيص الفوترة الذاتية دون تبادل أدوار الأطراف.

الخطوات التالية

الخلاصة

طبقة التصحيحات أصغر من مولّد الفواتير — ربما خُمس الكود — وتحمل أكثر من نصيبها من مخاطر التدقيق، لأن الإشعارات الدائنة هي حيث يتحرك المال إلى الوراء وحيث يصبح كل اختصار حسابي مركزاً مرئياً في إقرار ضريبي. الخيط الناظم لهذا الدليل أن كل قاعدة إماراتية أصبحت خاصية بنيوية: المرجع الإلزامي متغيّر اتحاد، وقاعدة منع السوالب حدّ في المُسلسِل، وثابت عدم التجاوز سجلٌّ يرمي، والترميز المزدوج مُعامل إعدادات، وملف الفوترة الذاتية قيمة. لا يمكن حذف أي من هذه الخصائص بتعديل متعجّل دون أن يعترض المُجمِّع أو حزمة الاختبارات — وهذا هو النوع الوحيد من الامتثال الذي ينجو من تبدّل الموظفين بين الآن وموعد التفويض.

إن كنت تحدد نطاق الفوترة الإلكترونية الإماراتية أمام موعد 2027 — تحصر أيّ تدفقات مستنداتك تنتج إشعارات دائنة، وأي علاقات تحتاج فوترة ذاتية، وأين بيانات التصحيح في نظام ERP لديك ليست نظيفة بما يكفي للتسلسل — فنحن نمارس عمل التكامل هذا احترافاً. تواصل معنا وأحضر أعقد سيناريو تصحيح لديك؛ فهي أسرع طريقة لاكتشاف أين يكمن العمل الحقيقي.