Eklentiniz, tenant'ın bağladığı fatura sağlayıcısı eklenti (Paraşüt/GİB entegratörü) üzerinden e-fatura/e-arşiv kestirebilir — sağlayıcının kimliğini bilmeden. messaging.send ile aynı eklentiler-arası capability altyapısını kullanır; tutarlar KURUŞ (integer) taşınır ve sonuç ASENKRON invoice.status event'iyle (issued/paid/void/failed) döner.
Tüketici fatura ister, sağlayıcıyı tenant seçer (Android chooser modeli). Platform-tarafı fatura altyapısı yoktur — sağlayıcı GİB/entegratöre keser. Alıcı { customerId } opak referanstır (PII değil); sağlayıcı fatura başlığını (VKN/unvan/adres) kendi customers:read'iyle çözer. Scope'lar: capability:invoice.issue:consume (tüketici) · capability:invoice.issue:provide (sağlayıcı).
provides:[{capability:"invoice.issue"}] (sağlayıcı) veya consumes:[{capability:"invoice.issue"}] (tüketici) beyan edin; türetilmiş scope otomatik eklenir.amount.total ve kalem unitPrice tam sayı kuruştur — 123.45 gibi ondalık gönderme; 12345 yaz (= ₺123,45). Float para = yuvarlama/mali hata; platform ondalığı invalidAmount ile reddeder.import { RestomenumClient } from '@restomenum/plugin-sdk'; // v1.9.0+
// TÜKETİCİ (scope: capability:invoice.issue:consume) — jenerik capability yolu.
// Sonuç tipi (InvoiceIssueResult) capability id'den OTOMATİK türer; elle <> tip argümanı GEREKMEZ.
const r = await client.capabilities.invoke('invoice.issue', {
to: { customerId: 'cust_123' }, // OPSİYONEL opak referans; sağlayıcı customers:read ile çözer
amount: { total: 12345, currency: 'TRY' }, // total KURUŞ (integer) → 12345 = ₺123,45. ondalık YOK
items: [ // opsiyonel kalemler (unitPrice de KURUŞ)
{ description: 'Adana Kebap', quantity: 2, unitPrice: 4500 },
{ description: 'Ayran', quantity: 3, unitPrice: 1115 },
],
reference: 'packet_789', // opsiyonel dış referans (paket/sipariş no)
idempotencyKey: `invoice-${packetId}`, // ZORUNLU — çift fatura koruması (mali/yasal risk)
});
// r = { requestId, status: 'accepted'|'issued'|'failed', providerMessageId?, idempotentReplay? }
// 'accepted' = kesim kuyruğa alındı (sonuç invoice.status event'iyle gelir); 'issued' = anında kesildi.
// Sağlayıcı bağlı değilse ApiError('plugin.invoice.noProvider', 424) → özelliği gizle.to yalnız opak { customerId } alır — ham müşteri PII (ad/VKN/telefon/adres) yasaktır (rawPiiForbidden). Ancak items[].description ve reference serbest metindir: içine kişisel veri gömmeyin (sağlayıcıya aynen gider). Fatura başlığı için gereken müşteri bilgisini sağlayıcı kendi customers:read'iyle customerId'den çözer.import { verifyAndParseCapability, capabilityResponse } from '@restomenum/plugin-sdk';
import type { InvoiceIssuePayload } from '@restomenum/plugin-sdk';
// SAĞLAYICI (scope: capability:invoice.issue:provide) — actionUrl'e gelen imzalı type:"capability" POST:
const req = await verifyAndParseCapability<InvoiceIssuePayload>(rawBody, headers['x-restomenum-signature'], {
getSecret: (tenantId) => installStore.find(tenantId)?.webhookSecret, // webhook ile AYNI imza şeması
});
if (!req) return res.status(401).json({ error: 'invalid_signature' });
// ZORUNLU dedupe: aynı req.requestId tekrar gelirse YENİDEN KESME (önceki yanıtı dön) — mükerrer fatura yasal sorun.
const { to, amount, items, reference } = req.payload;
// amount.total KURUŞ (integer). to.customerId → KENDİ customers:read'inle VKN/unvan/adres çöz (fatura başlığı).
// … GİB/e-fatura entegratörüne (Paraşüt/Uyumsoft…) kes …
res.json(capabilityResponse('accepted', { providerMessageId: invoiceNo })); // 'accepted' (async) veya 'issued' (anında)requestId ile ZORUNLU dedupeyapın; aynı istek tekrar gelirse yeniden kesmeyin, önceki fatura numarasını dönün. Tutarları KURUŞ integer olarak işleyin; cross-tenant kontrolü yapın (tenantId imzayla doğrulanır).messaging DLR'ı gibi, fatura sonucu asenkron gelir (GİB onayı dakikalar sürebilir). Sağlayıcı reportStatus ile bildirir; platform yalnız istek sahibi tüketiciye hedefli invoice.status event'i teslim eder (broadcast yok).
import type { InvoiceStatusReport, InvoiceStatusEventData } from '@restomenum/plugin-sdk';
// SAĞLAYICI — GİB sonucu geldiğinde (dakikalar sürebilir) asenkron durum raporla:
await client.capabilities.reportStatus('invoice.issue', {
requestId, // invoke yanıtındaki requestId
status: 'issued', // 'issued' | 'paid' | 'void' | 'failed'
providerMessageId: invoiceNo, // fatura no/UUID
} satisfies InvoiceStatusReport);
// TÜKETİCİ — invoice.status event'ine abone ol (yalnız İSTEK SAHİBİNE hedefli teslim, broadcast YOK):
// webhook handler'ında env.type === 'invoice.status':
const data = env.data as InvoiceStatusEventData; // { requestId, status, providerMessageId?, error? }
// data.status: issued→paid ileri gider; void/failed terminal. requestId ile kendi kaydınla eşle.issued → paid ileri gider; void (iptal) / failed (kesim hatası) terminaldir. Platform sırasız/gerileyen raporu yok sayar (monoton guard). Event at-least-once'tır — aynı (requestId, status) tekrarında zarf id'si aynıdır; tüketici id ile dedupe etsin.| Kod | HTTP | Anlamı |
|---|---|---|
| plugin.invoice.noProvider | 424 | Tenant fatura sağlayıcısı bağlamamış — önkoşul eksik. Özelliği gizle; retry etme. |
| plugin.invoice.providerUnavailable | 503 | Sağlayıcı inaktif/askıda/breaker-open/ulaşılamaz. |
| plugin.invoice.timeout | 504 | Sağlayıcı 10 sn içinde yanıtlamadı. AYNI idempotencyKey ile retry. |
| plugin.invoice.invalidAmount | 400 | amount.total pozitif integer (KURUŞ) olmalı — ondalık/float yasak. |
| plugin.invoice.invalidCurrency | 400 | currency yalnız TRY|USD|EUR. |
| plugin.invoice.invalidItems | 400 | items[]: description(≤200), quantity(pozitif integer), unitPrice(KURUŞ, ≥0); en çok 100 kalem. |
| plugin.invoice.rawPiiForbidden | 400 | to yalnız {customerId} alır; ham müşteri PII (ad/VKN/adres) yasak. |
| plugin.invoice.idempotencyKeyRequired | 400 | idempotencyKey eksik/geçersiz (zorunlu, ≤64). |
| plugin.invoice.consumerBlocked | 403 | Tenant, eklentinizin fatura kesmesini engellemiş. |
| plugin.invoice.duplicateInProgress | 409 | Aynı idempotencyKey eşzamanlı işleniyor. |
| plugin.invoice.idempotencyKeyReused | 409 | Aynı key FARKLI tutar/içerikle kullanıldı. |
| plugin.invoice.providerChanged | 409 | Belirsiz sonuçtan (timeout/unreachable) sonra tenant fatura sağlayıcısını DEĞİŞTİRDİ — orijinal sağlayıcı faturayı kesmiş olabilir; çift faturaya karşı bu key ile retry engellendi. Sonucu manuel uzlaştırın (yeni idempotencyKey ile yalnız kesilmediğinden EMİN olunca). |
| plugin.invoice.suspended | 403 | Eklentiniz kill-switch ile askıya alınmış — fatura kesemez. |
| plugin.invoice.selfTarget | 409 | Tüketici == bağlı sağlayıcı (kendine yönlendirme; binding çakışması). |
| plugin.capability.notFound | 404 | Bilinmeyen capability (jenerik /capabilities/{cap}/* ucunda). |
status:'failed' (senkron) sağlayıcının kesim reddidir (HTTP 200; iş sonucu). Transport hataları (timeout/unreachable) success:false döner — AYNI idempotencyKey ile retry edin. Çift kesim koruması sağlayıcı-tarafı requestId dedupe'undadır (aynı requestId aynı sağlayıcıya gider → sağlayıcı tekrar kesmez); ledger yalnız tekrar-relay'i yönetir, sağlayıcının işini geri almaz. Belirsiz sonuçtan sonra tenant sağlayıcıyı değiştirirse retry plugin.invoice.providerChanged(409) alır (çift fatura engeli) — bu durumda manuel uzlaştırma gerekir.
Fatura/mali belge kestiniz; peki fişe basılacak veriyi (mali blok, belge no) platforma nasıl iletirsiniz? Kapanış gate'inin yanıtıyla. table.close / packet.close gate'inde allow ile birlikte receiptExtras dizisi dönersiniz — öğeler kapanış yazılmadan önce adisyona işlenir. İkinci bir API çağrısı yoktur; karar ile veri aynı yanıtta gider.
tse.* namespace'i bu scope'a bağlıdır. Mali (KassenSichV/TSE) blok key'lerini yalnız capability:invoice.issue:provide yetkisi olan eklenti yazabilir; yetkisiz eklentinin tse.* key'i sessizce düşürülür ve hiçbir öğe verified işaretini almaz — sahte mali blok bastırılamaz. Şema, sınırlar (QR için 2000 karakter) ve yazım kuralları: Fişe Ek Alanlar (receiptExtras).