Skip to content

[TASK-SPLIT-BILLING-202608] Implementasi Unified Split Billing, Multi-KL Bypass, & Centralized Billing Helper Modul Pengangkut Kapal ​

Modul: Pengangkut | Tipe: Feature | Stage Saat Ini: STAGING | Tanggal Dokumen: 2026-09-01


1. Informasi Tugas & Referensi Bisnis ​

  • Task ID / Nomor Referensi: TASK-SPLIT-BILLING-202608

  • Sumber Instruksi: Penyesuaian Alur Bisnis Tagihan Pengangkut (Multi-KL Split Billing, Bypass Tagihan K/L, & Centralized Billing State Helper)

  • Dokumen Referensi / Lampiran: add_status_tagihan_split_billing.sql, split_billing_postman_collection.json, manual_billing_request_postman_collection.json

  • Task / Dependensi Terkait (Cross-Service):

  • Modul Terkait: Pengangkut

  • Service / Repository: pengangkut-backend

  • PIC Pengembang: Hermansyah Efendi

  • Latar Belakang & Ringkasan Kebutuhan: Implementasi arsitektur Unified Split Billing dan mekanisme Bypass Tagihan K/L secara menyeluruh untuk pengangkut kapal. Fitur ini memungkinkan admin memproses tagihan K/L yang dibayar secara mandiri/terpisah (bypass) sebelum kode billing Simponi terbit (Pre-Billing Bypass) maupun melakukan pembatalan dan penerbitan ulang billing setelah billing terbentuk (Post-Billing Void-Reset) secara atomik dalam satu transaksi database. Arsitektur ini mendukung pemrosesan banyak instansi K/L sekaligus dalam satu request (Multi-KL bypass_targets[]), penegakan guard bisnis (pencegahan total-bypass serta penolakan split pada billing yang sudah lunas), eliminasi N+1 database operations via batch transaction atomik, dan sentralisasi evaluasi status billing melalui modul helper app/helpers/billing.helper.js (parseBillingState dan fetchBillingDetails) sebagai Single Source of Truth (SSOT). Keseluruhan alur terintegrasi penuh dengan pemanggilan API Layanan Single Billing POST /api/v3/set-expired-billing (idempotent), evaluasi kelayakan 5 layanan berbayar Sinkarkes Kedatangan & PHQC Keberangkatan, pemisahan array response draft vs bypassed tagihan pada endpoint inquiry, serta selaras dengan validasi kelayakan (eligibility) dan kalkulasi inquiry Single Billing.

  • Ruang Lingkup & Rincian Sub-Pekerjaan (Technical Scope & Milestones):

    • [x] Milestone 1: Database Migration & Schema Definition (ENUM enum_status_tagihan, kolom status_tagihan, keterangan, updated_by, updated_date pada pengangkut.tagihan_hdr, backfill data, dan index performa).
    • [x] Milestone 2: Middleware & Security Guard (requireAdmin JWT role introspection groupGa 00, 07, 20).
    • [x] Milestone 3: Core Service Logic Split Billing (Pre-Billing Bypass dan Post-Billing Void-Reset secara atomik).
    • [x] Milestone 4: Single Billing V3 Integration & Query Optimization (POST /api/v3/set-expired-billing dengan penanganan idempotent expired, serta optimasi kueri checkDataBilling).
    • [x] Milestone 5: Integration with Eligibility & Inquiry (BillingEligibilityService dan InquiryBillingService, pemisahan array draft_tagihan [ACTIVE] vs bypassed_tagihan [BYPASSED]).
    • [x] Milestone 6: Simponi Payload Generator Update (payloadSingleBillingV2 dan payloadSingleBillingV3 hanya memproses tagihan ACTIVE).
    • [x] Milestone 7: API Controller & Strict Zod Validation (Endpoint POST /split-billing dengan validasi skema array bypass_targets).
    • [x] Milestone 8: Evaluasi Layanan Sinkarkes Kedatangan & Keberangkatan (Pemisahan 5 layanan berbayar kedatangan vs pemberitahuan_kedatangan gratis, serta kewajiban tagihan PHQC keberangkatan).
    • [x] Milestone 9: Multi-KL Bypass Targets & Batch Transaction (bypass_targets[] mendukung multi-instansi dan pemisahan leg Sinkarkes kedatangan/keberangkatan, dieksekusi dalam 1 database transaction atomik).
    • [x] Milestone 10: Guard Proteksi Total-Bypass & Billing Paid (Penolakan split billing jika semua tagihan di-bypass atau billing sudah berstatus lunas).
    • [x] Milestone 11: Centralized Billing State Helper (Pembuatan modul app/helpers/billing.helper.js dengan parseBillingState dan fetchBillingDetails sebagai Single Source of Truth).
    • [x] Milestone 12: Comprehensive Unit Testing (Pembuatan test suite Vitest dengan 85 passed tests across 7 test suites dan 100% line coverage pada modul split billing).

2. Deployment Lifecycle ​

#EnvironmentTanggal & WaktuPICStatusCatatan
1Development2026-09-01 13:48Hermansyah EfendiDEPLOYEDBranch: feat/split-billing
2Staging (UAT)2026-09-09 11:34achmad fauziDEPLOYEDRelease Candidate v1.18.2 via MR !2080. Co-deployed: [TASK-DRAGONFLYDB-MIGRATION, TASK-SPLIT-BILLING-202608, FEAT-MANUAL-BILLING-REQUEST, MAINT-SUPERSET-SUMMARY-SHIP-SYNC-SECURITY, REFACTOR-DEPRECATE-POST-STATUS-SERVICE-MIGRATION, TASK-PINO-LOGGER-REFACTOR, ND-304-LNSW.34-2026, ND-256-LNSW.34-2026, ND-183-LNSW.34-2026]
3Production——PENDING—

Riwayat Update Log ​

TanggalEnvironmentPerubahanPIC
2026-09-09StagingPromosi ke STAGING via MR !2080 (Release Candidate v1.18.2)Hermansyah Efendi
2026-09-01DevelopmentPenambahan dukungan Multi-KL bypass targets (array bypass_targets[]), eksekusi batch transaction atomik di Pre-Billing, modul helper terpusat billing.helper.js (parseBillingState & fetchBillingDetails), guard total-bypass & billing paid, pemisahan leg Sinkarkes kedatangan/keberangkatan, dan update test suites (85 passed tests across 7 test suites, 100% line coverage pada split billing)Hermansyah Efendi
2026-08-31DevelopmentPenyelarasan logika evaluasi Sinkarkes 5 layanan berbayar kedatangan & PHQC keberangkatan pada BillingEligibilityService, konsolidasi alur Manual Request Billing & Idempotency, penambahan Postman Collection JSON, dan update test suites (55 passed tests)Hermansyah Efendi
2026-08-27DevelopmentPemisahan array draft_tagihan (ACTIVE) dan bypassed_tagihan (BYPASSED) pada response endpoint inquiry billing (InquiryBillingService)Hermansyah Efendi
2026-08-27DevelopmentIntegrasi Single Billing API v3 set-expired-billing saat void tagihan, penanganan idempotent expired, optimasi kueri checkDataBilling, dan update test suites (commit 6e94a402, 16b2bcda)Hermansyah Efendi
2026-08-26DevelopmentInisialisasi draft audit log dari context block backend (commit d97fcdcc)Hermansyah Efendi

3. Analisis Teknis & Rincian Perubahan ​

  • Alur Logika & Bisnis:

    • Multi-KL Payload Schema (bypass_targets[]): Request split billing mendukung pemrosesan banyak target instansi sekaligus dalam 1 request. Tiap elemen memuat kode_kl (wajib), opsional id_tagihan_hdr (array positive integer), dan opsional jenis_pengajuan ("kedatangan" atau "keberangkatan" untuk K/L Sinkarkes 024).
    • Pre-Billing Batch Execution: Eksekusi bypass sebelum billing terbit dilakukan dalam 1 transaksi database atomik (sequelizeDb.transaction()). Menggunakan single query lookup, single query bulk update, dan single query bulk create placeholder guna mengeliminasi N+1 database operations.
    • Post-Billing Guard & Void-Reset:
      • Guard Paid Check: Menolak split billing jika billing induk sudah lunas (fl_paid = true).
      • Guard 4 (Total-Bypass Prevention): Menolak pembatalan jika seluruh tagihan dalam batch akan di-bypass (minimal 1 tagihan wajib berstatus ACTIVE agar dapat diterbitkan billing Simponi baru).
      • Sinkarkes Leg Matching: Membedakan tagihan kedatangan (prefix 101) dan keberangkatan (prefix 102) saat melakukan void dan penyalinan tagihan baru.
    • Centralized Billing Helper Module (app/helpers/billing.helper.js): Mengonsolidasi logika penentuan status billing (hasBillingRecord, flBilling, isPaid, flPayment, isBillingExpired, isBillingGenerated, idBilling, activeKodeBilling, fetchBillingDetails) sehingga seluruh consumer file (split-billing, billing-eligibility, inquiry-billing, request-billing, superset-summary) memiliki definisi status yang seragam dan DRY.
    • Integrasi Single Billing V3 Set-Expired (voidAndResetTagihan): Mengambil trx_id_billing dari header tagihan aktif dan memanggil billingApi.setExpiredBillingV3({ trx_id_insw: trxIdBilling, alasan }) untuk menandai status expired pada Layanan Single Billing dengan penanganan idempotent jika status sudah expired.
    • Evaluasi Spesifik Sinkarkes Kedatangan vs Keberangkatan (BillingEligibilityService):
      • Kedatangan: Backend memeriksa kolom boolean layanan pada pengangkut.data_sinkarkes (sertifikat_air, cop, healthbook, p3k, sscec). Tagihan kedatangan hanya diwajibkan jika pemohon mengajukan minimal satu layanan berbayar. Pengajuan pemberitahuan_kedatangan murni dikategorikan non-berbayar (gratis).
      • Keberangkatan: Layanan pemeriksaan keberangkatan (PHQC) selalu mewajibkan ketersediaan tagihan keberangkatan (hasSinkarkesDeparture atau sinkarkesDepartureBypassed).
    • Daftar File yang Berubah:
      • app/helpers/billing.helper.js [Helper Module]
      • app/services/split-billing.service.js [Service / Business Logic]
      • app/services/billing-eligibility.service.js [Service / Business Logic]
      • app/services/inquiry-billing.service.js [Service / Business Logic]
      • app/services/request-billing.service.js [Service / Business Logic]
      • app/services/superset-summary.service.js [Service / Business Logic]
      • app/controllers/superset_summary_controller.js [Controller / API Route]
      • app/controllers/v2/billing.controller.js [Controller / API Route]
      • app/validations/billing.validation.js [Validation / Schema]
      • app/models/billing_models.js [SQL Model Helper]
      • tests/helpers/billing.helper.test.js [Unit Test]
      • tests/validations/billing.validation.test.js [Unit Test]
      • tests/controllers/v2/billing.controller.test.js [Unit Test]
      • tests/services/split-billing.service.test.js [Unit Test]
      • tests/services/billing-eligibility.service.test.js [Unit Test]
      • tests/services/inquiry-billing.service.test.js [Unit Test]
      • tests/services/request-billing.service.test.js [Unit Test]
  • Perubahan Database (DDL / DML):

    sql
    -- Step 1: Buat ENUM Type untuk status_tagihan (Strict Type)
    DO $$
    BEGIN
      IF NOT EXISTS (
        SELECT 1 FROM pg_type t 
        JOIN pg_namespace n ON n.oid = t.typnamespace 
        WHERE t.typname = 'enum_status_tagihan' AND n.nspname = 'pengangkut'
      ) THEN
        CREATE TYPE pengangkut.enum_status_tagihan AS ENUM ('ACTIVE', 'BYPASSED', 'VOIDED');
      END IF;
    END$$;
    
    -- Step 2: Tambah kolom baru ke tagihan_hdr menggunakan ENUM type
    ALTER TABLE pengangkut.tagihan_hdr
      ADD COLUMN IF NOT EXISTS status_tagihan pengangkut.enum_status_tagihan DEFAULT 'ACTIVE',
      ADD COLUMN IF NOT EXISTS updated_by     varchar(50)  NULL,
      ADD COLUMN IF NOT EXISTS updated_date   timestamptz(0) NULL,
      ADD COLUMN IF NOT EXISTS keterangan   text         NULL;
    
    -- Step 3: Backfill data lama agar tidak NULL
    UPDATE pengangkut.tagihan_hdr
    SET status_tagihan = 'ACTIVE'
    WHERE status_tagihan IS NULL;
    
    -- Step 4: Index performa untuk query lookup split billing
    CREATE INDEX IF NOT EXISTS idx_tagihan_hdr_trx_status 
    ON pengangkut.tagihan_hdr (trx_id_insw, status_tagihan);
  • Kontrak API & Response Payload:

    1. Endpoint Split Billing (Admin Action) ​

    POST /split-billing ​
    • Deskripsi: Endpoint terpadu untuk memisahkan/melewati (bypass) multi-tagihan K/L dari alur billing kolektif. Backend secara otomatis mendeteksi fase Pre-Billing atau Post-Billing.
    • Autentikasi / Security Guard: Bearer Token JWT (Wajib lolos doIntrospection dan requireAdmin dengan groupGa 00, 07, atau 20).
    • Request Header:
      • Authorization: Bearer <token>
      • Content-Type: application/json
    A. Skema Validasi Request Payload (Zod Schema SplitBillingSchema) ​
    Field PathTipe DataWajib/OpsionalKeterangan & Aturan Validasi
    service_numberStringWajibNomor aju keberangkatan (Wajib diawali prefix 102).
    bypass_targetsArray<Object>WajibArray target bypass (minimal 1 item).
    bypass_targets[].kode_klStringWajibEnum: "022" (Inaportnet) atau "024" (Sinkarkes).
    bypass_targets[].id_tagihan_hdrArray<Number>OpsionalArray positive integers ID header tagihan spesifik.
    bypass_targets[].jenis_pengajuanStringOpsionalEnum: "kedatangan" atau "keberangkatan" (Spesifik Sinkarkes 024).
    keteranganStringWajibAlasan split billing (Minimal 5 karakter).

    Contoh Payload Request Multi-KL:

    json
    {
      "service_number": "10202408260001",
      "bypass_targets": [
        {
          "kode_kl": "024",
          "jenis_pengajuan": "kedatangan"
        },
        {
          "kode_kl": "022",
          "id_tagihan_hdr": [105]
        }
      ],
      "keterangan": "Tagihan Sinkarkes kedatangan dan Warta 105 dibayar mandiri oleh agen"
    }
    B. Response Pre-Billing Stage (200 OK) ​
    json
    {
      "data": {
        "stage": "PRE_BILLING",
        "message": "Split billing tagihan K/L berhasil dilakukan (Pre-Billing)",
        "results": [
          {
            "kode_kl": "024",
            "jenis_pengajuan": "kedatangan",
            "status_tagihan": "BYPASSED",
            "keterangan": "Tagihan Sinkarkes kedatangan dibayar mandiri",
            "updated_by": "admin-1",
            "affected_headers": [
              {
                "id_tagihan_hdr": 102,
                "kode_kl": "024",
                "trx_id_insw": "10102408260001",
                "status_tagihan": "BYPASSED"
              }
            ]
          },
          {
            "kode_kl": "022",
            "jenis_pengajuan": null,
            "status_tagihan": "BYPASSED",
            "keterangan": "Tagihan Warta dibayar mandiri",
            "updated_by": "admin-1",
            "affected_headers": [
              {
                "id_tagihan_hdr": 105,
                "kode_kl": "022",
                "trx_id_insw": "10202408260001",
                "status_tagihan": "BYPASSED"
              }
            ]
          }
        ]
      },
      "message": "Split billing tagihan K/L berhasil dilakukan (Pre-Billing)",
      "statusCode": 200,
      "success": true
    }
    C. Response Post-Billing Stage (200 OK) ​
    json
    {
      "data": {
        "stage": "POST_BILLING",
        "message": "3 tagihan berhasil di-void dan di-reset",
        "voided_count": 3,
        "reset_tagihan": [
          {
            "id_tagihan_hdr_lama": 101,
            "status_tagihan": "ACTIVE",
            "hdr": { "id_tagihan_hdr": 201, "status_tagihan": "ACTIVE" },
            "dtl_count": 1
          },
          {
            "id_tagihan_hdr_lama": 102,
            "status_tagihan": "BYPASSED",
            "hdr": { "id_tagihan_hdr": 202, "status_tagihan": "BYPASSED" },
            "dtl_count": 1
          },
          {
            "id_tagihan_hdr_lama": 103,
            "status_tagihan": "ACTIVE",
            "hdr": { "id_tagihan_hdr": 203, "status_tagihan": "ACTIVE" },
            "dtl_count": 1
          }
        ]
      },
      "message": "3 tagihan berhasil di-void dan di-reset",
      "statusCode": 200,
      "success": true
    }
    D. Penanganan Error & Business Guards ​
    • Guard Total-Bypass Ditolak (400 Bad Request):

      json
      {
        "data": null,
        "message": "Tidak bisa mem-bypass seluruh tagihan. Minimal 1 tagihan harus tetap ACTIVE setelah split.",
        "statusCode": 400,
        "success": false,
        "errors": []
      }
    • Guard Billing Lunas Ditolak (400 Bad Request):

      json
      {
        "data": null,
        "message": "Billing sudah lunas, tidak dapat melakukan split billing",
        "statusCode": 400,
        "success": false,
        "errors": []
      }
    • Bypass Pre-Billing Tagihan Sudah Terbit Billing (400 Bad Request):

      json
      {
        "data": null,
        "message": "Bypass gagal. Kode billing sudah diterbitkan untuk tagihan ini.",
        "statusCode": 400,
        "success": false,
        "errors": []
      }
    • Validasi Nomor Aju (Prefix Non-102):

      json
      {
        "data": null,
        "message": "Split billing hanya dapat dilakukan menggunakan nomor aju keberangkatan",
        "statusCode": 400,
        "success": false,
        "errors": []
      }
    • Akses Ditolak (Non-Admin Role):

      json
      {
        "data": null,
        "message": "Akses ditolak. Endpoint ini hanya dapat diakses oleh Admin.",
        "statusCode": 403,
        "success": false
      }

4. Bukti Teknis (Technical Proof & Audit Trail) ​

Daftar Commit Terkait (Multi-Commit Trail) ​

Commit IDBranchAuthorTanggal & JamRingkasan Perubahan
d1f7f125834e3cf98df51dc11805135280167e49feat/split-billingHermansyah Efendi2026-09-01 13:48feat(billing): support multi-KL bypass targets and centralize billing state helper
10f795c5c22dfd7ab84bf2efb94e770a2aa0cde9feat/split-billingHermansyah Efendi2026-09-01 09:34fix(billing): handle expired status correctly for paid and active billings
5cdbb0be4b1e7a9cf1967bb35c9b9ac3651b1bfefeat/split-billingHermansyah Efendi2026-08-31 18:03feat(billing): separate sinkarkes requirements by leg and fix bypass id priority in void reset
d3d19610feat/manual-billing-requestHermansyah Efendi2026-08-27 15:45feat(billing): separate draft_tagihan and bypassed_tagihan in inquiry response
6e94a402feat/manual-billing-requestHermansyah Efendi2026-08-27 15:21feat(billing): integrate single billing set-expired-billing v3 API and optimize query
d97fcdccfeat/manual-billing-requestHermansyah Efendi2026-08-26 16:47feat(billing): implement unified split billing and bypass tagihan KL module

Cuplikan Kode / Implementasi Inti ​

javascript
// 1. Modul Helper Terpusat (app/helpers/billing.helper.js)
export const parseBillingState = (billingData) => {
  const row = Array.isArray(billingData) && billingData.length > 0 ? billingData[0] : null;
  const hasBillingRecord = Boolean(row && row.kode_billing_induk);
  const isPaid = Boolean(hasBillingRecord && row.fl_paid === true);
  const isBillingExpired = Boolean(hasBillingRecord && !isPaid && row.is_expired === true);
  const isBillingGenerated = Boolean(hasBillingRecord && !isBillingExpired);
  const idBilling = isBillingGenerated ? row.trx_id_insw : null;
  const activeKodeBilling = isBillingGenerated ? row.kode_billing_induk : null;
  return {
    hasBillingRecord,
    flBilling: hasBillingRecord,
    isPaid,
    flPayment: isPaid,
    isBillingExpired,
    isBillingGenerated,
    idBilling,
    activeKodeBilling,
    raw: row,
  };
};

// 2. Batch Execution & Transaksi Atomik Pre-Billing (app/services/split-billing.service.js)
async bypassTagihan({ trx_ids, service_number, serviceNumberKedatangan, bypass_targets, keterangan, admin_id }) {
  const t = await sequelizeDb.transaction();
  try {
    const existingHeaders = await TagihanHdr.findAll({
      where: {
        trx_id_insw: { [Op.in]: trx_ids },
        status_tagihan: { [Op.ne]: STATUS_TAGIHAN.VOIDED },
      },
      transaction: t,
    });
    const idsToUpdate = new Set();
    const placeholdersToCreate = [];
    const results = [];
    for (const item of bypass_targets) {
      const { kode_kl, id_tagihan_hdr, jenis_pengajuan } = item;
      const headerIds = Array.isArray(id_tagihan_hdr) && id_tagihan_hdr.length > 0
        ? id_tagihan_hdr.map(id => parseInt(id, 10))
        : [];
      let matchedHeaders = [];
      if (headerIds.length > 0) {
        const idSet = new Set(headerIds);
        matchedHeaders = existingHeaders.filter(h => idSet.has(h.id_tagihan_hdr));
      } else if (kode_kl === KODE_KL.SINKARKES && jenis_pengajuan) {
        const targetTrxId = jenis_pengajuan === 'kedatangan' ? serviceNumberKedatangan : service_number;
        if (!targetTrxId) {
          throw new BadRequestError(`Tidak ditemukan nomor aju ${jenis_pengajuan} untuk transaksi ini.`);
        }
        matchedHeaders = existingHeaders.filter(h => h.kode_kl === kode_kl && h.trx_id_insw === targetTrxId);
      } else {
        matchedHeaders = existingHeaders.filter(h => h.kode_kl === kode_kl);
      }
      if (matchedHeaders.length > 0) {
        if (matchedHeaders.some(h => h.fl_billing === true)) {
          throw new BadRequestError('Bypass gagal. Kode billing sudah diterbitkan untuk tagihan ini.');
        }
        matchedHeaders.forEach(h => idsToUpdate.add(h.id_tagihan_hdr));
        results.push({
          kode_kl,
          jenis_pengajuan: jenis_pengajuan || null,
          status_tagihan: STATUS_TAGIHAN.BYPASSED,
          keterangan,
          updated_by: admin_id,
          affected_headers: matchedHeaders.map(h => ({
            id_tagihan_hdr: h.id_tagihan_hdr,
            kode_kl: h.kode_kl,
            trx_id_insw: h.trx_id_insw,
            status_tagihan: STATUS_TAGIHAN.BYPASSED,
          })),
        });
      } else {
        let placeholderTrxId = service_number;
        if (kode_kl === KODE_KL.SINKARKES && jenis_pengajuan === 'kedatangan') {
          placeholderTrxId = serviceNumberKedatangan || service_number;
        }
        placeholdersToCreate.push({
          item,
          data: {
            trx_id_insw: placeholderTrxId,
            kode_kl,
            status_tagihan: STATUS_TAGIHAN.BYPASSED,
            keterangan,
            updated_by: admin_id,
            updated_date: new Date(),
            fl_billing: false,
            fl_payment: false,
            total_nominal_billing: '0',
          },
        });
      }
    }
    if (idsToUpdate.size > 0) {
      await TagihanHdr.update(
        { status_tagihan: STATUS_TAGIHAN.BYPASSED, keterangan, updated_by: admin_id, updated_date: new Date() },
        { where: { id_tagihan_hdr: { [Op.in]: Array.from(idsToUpdate) } }, transaction: t }
      );
    }
    if (placeholdersToCreate.length > 0) {
      const createdRecords = await TagihanHdr.bulkCreate(placeholdersToCreate.map(p => p.data), { transaction: t });
      createdRecords.forEach((newHdr, idx) => {
        const { item } = placeholdersToCreate[idx];
        results.push({
          kode_kl: newHdr.kode_kl,
          jenis_pengajuan: item.jenis_pengajuan || null,
          status_tagihan: newHdr.status_tagihan,
          keterangan: newHdr.keterangan,
          updated_by: newHdr.updated_by,
          affected_headers: [{ id_tagihan_hdr: newHdr.id_tagihan_hdr, kode_kl: newHdr.kode_kl, trx_id_insw: newHdr.trx_id_insw, status_tagihan: newHdr.status_tagihan }],
        });
      });
    }
    await t.commit();
    return results;
  } catch (err) {
    await t.rollback();
    throw err;
  }
}

5. Bukti Verifikasi Pengujian (QA / Unit Test / API Verification) ​

A. Verifikasi Unit Test (Development Stage) ​

  • Test Suite File: tests/helpers/billing.helper.test.js, tests/validations/billing.validation.test.js, tests/controllers/v2/billing.controller.test.js, tests/services/billing-eligibility.service.test.js, tests/services/inquiry-billing.service.test.js, tests/services/request-billing.service.test.js, tests/services/split-billing.service.test.js
  • Status Eksekusi CLI: PASSED (85/85 tests passed, 100% line coverage on split billing)
text
 RUN  v4.1.10 D:/WORK/LNSW/pengangkut-backend
 ✓ tests/helpers/billing.helper.test.js (8 tests) 14ms
 ✓ tests/services/inquiry-billing.service.test.js (7 tests) 22ms
 ✓ tests/services/billing-eligibility.service.test.js (17 tests) 22ms
 ✓ tests/validations/billing.validation.test.js (14 tests) 17ms
 ✓ tests/services/request-billing.service.test.js (10 tests) 32ms
 ✓ tests/controllers/v2/billing.controller.test.js (5 tests) 20ms
 ✓ tests/services/split-billing.service.test.js (24 tests) 68ms

 Test Files  7 passed (7)
      Tests  85 passed (85)
   Start at  13:57:03
   Duration  910ms (transform 559ms, setup 0ms, import 1.43s, tests 196ms, environment 2ms)

B. Panduan Pengujian QA & Verifikasi Staging (QA Playbook) ​

Diisi saat development sebagai panduan eksekusi pengujian QA, kemudian dilengkapi bukti response JSON saat verifikasi di Staging.

  • Cakupan / Surface Pengujian: Full E2E (UI + Backend)
  • Kebutuhan Akses / Role Akun: Role Admin (groupGa 00, 07, 20) & Role Agen Pengangkut
  • Postman Collection File: split_billing_postman_collection.json, manual_billing_request_postman_collection.json
  • Hasil Pengujian UAT: PENDING
  • Catatan QA: Unit test lokal (85 tests across 7 test suites) telah lulus secara empiris dengan 100% line coverage pada modul split billing. Skenario uji E2E dapat dijalankan menggunakan skenario di bawah ini.

Detailed QA Playbook: Modul Split Billing (POST /split-billing) ​
  • Endpoint: POST /split-billing
  • Autentikasi / Guard: Bearer Token (doIntrospection, requireAdmin dengan groupGa 00, 07, 20)
A. Skenario Positive (Happy Path Testing) ​

Skenario 1 — Pre-Billing Baseline: Bypass Sinkarkes Full (Kasus Operasional Utama) ​

  • Tujuan: Mem-bypass seluruh tagihan Sinkarkes (024) baik kedatangan maupun keberangkatan sebelum kode billing terbit, sehingga hanya tagihan Inaport (022) yang diproses.
  • Pre-kondisi Data: Aju keberangkatan 1020x terhubung dengan kedatangan 1010x. Tagihan 022 dan 024 (kedatangan & keberangkatan) sudah ada di DB dengan fl_billing = false dan status_tagihan = 'ACTIVE'.
  • Trigger Request:
    • METHOD: POST
    • URL: /split-billing
    • HEADERS:
      • Content-Type: application/json
      • Authorization: Bearer <adminToken>
    • BODY:
      json
      {
        "service_number": "1020x",
        "bypass_targets": [
          { "kode_kl": "024" }
        ],
        "keterangan": "Bypass Sinkarkes penuh agar tagihan Inaport dapat terbit terlebih dahulu"
      }
  • Ekspektasi Response (200 OK):
    • stage: "PRE_BILLING"
    • results: Array berisi 1 grup hasil bypass untuk kode K/L 024.
  • Verifikasi Database (pengangkut.tagihan_hdr):
    • Seluruh tagihan 024 (SINKARKES) pada aju 1010x dan 1020x berubah status menjadi BYPASSED dengan kolom keterangan dan updated_by terisi.
    • Tagihan 022 tetap berstatus ACTIVE.

Skenario 2 — Pre-Billing Placeholder: Bypass Sinkarkes Sebelum Data Landing di DB ​

  • Tujuan: Memastikan sistem berhasil membuat baris placeholder BYPASSED ketika admin melakukan split sebelum K/L Sinkarkes mengirimkan tagihan.
  • Pre-kondisi Data: Aju Keberangkatan 1020x hanya memiliki tagihan 022 (INAPORT) di DB. Belum ada baris tagihan 024 sama sekali. Billing belum terbit.
  • Trigger Request:
    • METHOD: POST
    • URL: /split-billing
    • HEADERS:
      • Content-Type: application/json
      • Authorization: Bearer <adminToken>
    • BODY:
      json
      {
        "service_number": "1020x",
        "bypass_targets": [
          { "kode_kl": "024" }
        ],
        "keterangan": "Pre-bypass Sinkarkes sebelum data terkirim ke DB"
      }
  • Ekspektasi Response (200 OK):
    • stage: "PRE_BILLING"
    • results[0].affected_headers: Mengembalikan placeholder header baru hasil insert.
  • Verifikasi Database (pengangkut.tagihan_hdr):
    • Terbentuk baris baru: trx_id_insw: '1020x', kode_kl: '024', status_tagihan: 'BYPASSED', total_nominal_billing: '0', fl_billing: false.

Skenario 3 — Pre-Billing Multi-KL Split: 022 & 024 Kedatangan (Variasi Lanjutan) ​

  • Tujuan: Mem-bypass tagihan multi-instansi (Inaport 022 dan Sinkarkes Kedatangan 024) secara bersamaan sebelum billing terbit.
  • Pre-kondisi Data: Aju Keberangkatan 1020x terhubung dengan kedatangan 1010x. Tagihan 022 dan 024 (kedatangan & keberangkatan) ada di DB, fl_billing = false.
  • Trigger Request:
    • METHOD: POST
    • URL: /split-billing
    • HEADERS:
      • Content-Type: application/json
      • Authorization: Bearer <adminToken>
    • BODY:
      json
      {
        "service_number": "1020x",
        "bypass_targets": [
          { "kode_kl": "022" },
          { "kode_kl": "024", "jenis_pengajuan": "kedatangan" }
        ],
        "keterangan": "Tagihan Inaport dan Sinkarkes Kedatangan dibayar mandiri"
      }
  • Ekspektasi Response (200 OK):
    • stage: "PRE_BILLING"
    • results: Array berisi 2 grup hasil bypass (022 dan 024 kedatangan).
  • Verifikasi Database (pengangkut.tagihan_hdr):
    • Seluruh tagihan 022 berubah menjadi BYPASSED.
    • Tagihan 024 kedatangan (101...) berubah menjadi BYPASSED.
    • Tagihan 024 keberangkatan (102...) tetap ACTIVE.

Skenario 4 — Post-Billing Baseline: Void & Reset Bypass Sinkarkes Full (Kasus Operasional Utama) ​

  • Tujuan: Membatalkan kode billing gabungan (Inaport + Sinkarkes) yang sudah terbit di Layanan Single Billing, lalu mereset tagihan sehingga hanya tagihan Inaport yang siap diterbitkan ulang.
  • Pre-kondisi Data:
    • Kode billing aktif sudah terbit di Layanan Single Billing untuk aju 1020x (fl_paid = false, belum expired).
    • DB memiliki 3 tagihan aktif: 022, 024 kedatangan, 024 keberangkatan (fl_billing = true, status_tagihan = 'ACTIVE').
  • Trigger Request:
    • METHOD: POST
    • URL: /split-billing
    • HEADERS:
      • Content-Type: application/json
      • Authorization: Bearer <adminToken>
    • BODY:
      json
      {
        "service_number": "1020x",
        "bypass_targets": [
          { "kode_kl": "024" }
        ],
        "keterangan": "Void billing gabungan untuk memisahkan seluruh tagihan Karantina"
      }
  • Ekspektasi Response (200 OK):
    • stage: "POST_BILLING"
    • voided_count: 3
    • message: "3 tagihan berhasil di-void dan di-reset"
  • Verifikasi Eksternal & Database:
    • Layanan Single Billing: Kode billing lama berstatus EXPIRED via billingApi.setExpiredBillingV3.
    • pengangkut.tagihan_hdr (Record Lama): 3 tagihan lama berubah menjadi VOIDED.
    • pengangkut.tagihan_hdr (Record Baru Hasil Clone):
      • Tagihan baru 022: status_tagihan = 'ACTIVE', fl_billing = false, fl_payment = false, trx_id_billing = null.
      • Tagihan baru 024 (kedatangan & keberangkatan): status_tagihan = 'BYPASSED', keterangan terisi, fl_billing = false.
    • pengangkut.tagihan_dtl: Seluruh baris detail tagihan lama tersalin utuh mengarah ke id_tagihan_hdr baru.

Skenario 5 — Post-Billing Void & Reset Parsial: Bypass Sinkarkes Keberangkatan (Variasi Lanjutan) ​

  • Tujuan: Membatalkan kode billing aktif dan mereset tagihan dengan hanya mem-bypass Sinkarkes Keberangkatan, sedangkan Inaport dan Sinkarkes Kedatangan tetap diproses pada billing baru.
  • Pre-kondisi Data: Kode billing aktif menggabungkan 022, 024 kedatangan, dan 024 keberangkatan.
  • Trigger Request:
    • METHOD: POST
    • URL: /split-billing
    • HEADERS:
      • Content-Type: application/json
      • Authorization: Bearer <adminToken>
    • BODY:
      json
      {
        "service_number": "1020x",
        "bypass_targets": [
          { "kode_kl": "024", "jenis_pengajuan": "keberangkatan" }
        ],
        "keterangan": "Bypass Sinkarkes Keberangkatan pasca billing terbit"
      }
  • Ekspektasi Response (200 OK):
    • stage: "POST_BILLING"
    • voided_count: 3
  • Verifikasi Database (pengangkut.tagihan_hdr):
    • 3 tagihan lama menjadi VOIDED.
    • 3 tagihan baru terbentuk:
      • 022: status_tagihan = 'ACTIVE'.
      • 024 Kedatangan: status_tagihan = 'ACTIVE'.
      • 024 Keberangkatan: status_tagihan = 'BYPASSED'.
B. Skenario Negative (Validation, Auth, & Business Rule Testing) ​
  1. Skenario 6 — Guard Total-Bypass Ditolak:

    • Pre-kondisi: Billing aktif hanya memiliki tagihan 022 dan 024.
    • Trigger Request: POST /split-billing dengan bypass_targets: [{ "kode_kl": "022" }, { "kode_kl": "024" }].
    • Ekspektasi Response (400 Bad Request):
      json
      {
        "data": null,
        "message": "Tidak bisa mem-bypass seluruh tagihan. Minimal 1 tagihan harus tetap ACTIVE setelah split.",
        "statusCode": 400,
        "success": false,
        "errors": []
      }
    • Verifikasi Database: Transaksi di-rollback penuh, tidak ada tagihan yang di-void.
  2. Skenario 7 — Guard Billing Lunas (Paid) Ditolak:

    • Pre-kondisi: Billing sudah dibayar (fl_paid: true).
    • Trigger Request: POST /split-billing.
    • Ekspektasi Response (400 Bad Request):
      json
      {
        "data": null,
        "message": "Billing sudah lunas, tidak dapat melakukan split billing",
        "statusCode": 400,
        "success": false,
        "errors": []
      }
  3. Skenario 8 — Otorisasi Non-Admin (RBAC Security):

    • Pre-kondisi: Token otentikasi akun non-admin (groupGa selain 00, 07, 20).
    • Ekspektasi Response (403 Forbidden):
      json
      {
        "data": null,
        "message": "Akses ditolak. Endpoint ini hanya dapat diakses oleh Admin.",
        "statusCode": 403,
        "success": false
      }
  4. Skenario 9 — Validasi Nomor Aju Bukan Keberangkatan (Non-102):

    • Payload: "service_number": "101202409010001" (prefix 101).
    • Ekspektasi Response (400 Bad Request):
      json
      {
        "data": null,
        "message": "Split billing hanya dapat dilakukan menggunakan nomor aju keberangkatan",
        "statusCode": 400,
        "success": false,
        "errors": []
      }

Bukti Response Verifikasi Staging (Diisi saat UAT) ​
json
{
  "test_suites": [
    "tests/helpers/billing.helper.test.js (PASS)",
    "tests/validations/billing.validation.test.js (PASS)",
    "tests/controllers/v2/billing.controller.test.js (PASS)",
    "tests/services/billing-eligibility.service.test.js (PASS)",
    "tests/services/inquiry-billing.service.test.js (PASS)",
    "tests/services/request-billing.service.test.js (PASS)",
    "tests/services/split-billing.service.test.js (PASS)"
  ],
  "verification_status": "DEV_PASSED",
  "note": "Menunggu deployment ke environment Staging untuk eksekusi skenario uji QA."
}

Single Source of Truth (SSOT) Arsitektur & Perubahan Teknis Backend