Street Programmer: Print Receipt From Online POS Web App to Local Thermal Printer
Tags: javascript, react, php, webdev
Published: 2023-09-02 19:55:00

Before I tell you more about how I created a Websocket server with PHP to provide communication access between online web apps and local thermal printers, I would like to express my gratitude to donors who have provided support for what I'm doing here.

Donors:
{% embed https://dev.to/ingosteinke %}

GitHub Sponsors:
{% embed https://dev.to/mkubdev %}

Previous story...

{% embed https://dev.to/darkterminal/build-warehouse-and-store-management-system-pt-3-3jdo %}

---

Hello punk! I am Freestyler, many question a had on internet about _**how to print from online web app to local printer "thermal"?**._ Yep! Thermal Printer that usually used in POS Application, sound like whaaaaat.... (just kidding, nothing!)

![How To Do That](https://i.imgflip.com/72iewi.jpg)

Sorry... am narcissistic (in my head) person but yeah (am health BTW). So, _**what is my receipt?**_

## The Receipt (Requirements)

In a long time ago am PHP Developer and then I have _backstreet_ relationship (try to make it word like Hacker) with JavaScript (and falling in love with it) and now I am Playgrammer.

**The Framework**
- [React JS](https://reactjs.org/) My young fiance
- [Workerman](https://github.com/walkor/workerman) My old lady that can create WebSocket Server with easy!

Both of them it's just like Documentation.

> _When it's good, it's very good. When it's bad, it's better than nothing. - Dick Brandon_

**The Dependencies**
- [React useWebSocket Hook](https://www.npmjs.com/package/react-use-websocket) WebSocket Client to talk with his mama
- [ESCPOS-PHP](https://github.com/mike42/escpos-php) blank paper that client can wrote their homework and share with his mama
- [Dexie.js](dexie.org/) IndexedDB client to store printer setting (optional)

**The Devices**
- Your Computer / Laptop (nah!)
- Any Thermal Printer that [compatible](https://github.com/mike42/escpos-php#printers) with [ESCPOS-PHP Package](https://github.com/mike42/escpos-php)

**Additional Requirement**
- Coffee - **Required!**
- Ciggarettes - **Required!**
- Music that you hate so much! - **Required!**

## Open The Door

Tok tok tok... open the door please. Open your unusual Text Editor and open terminal window then create your brand new react app! (FYI: I will not tell you how to create react brand new react app, just read their documentation!)

After create brand new react app, then install `react-use-websocket` using composer (it's wrong way dude!).

> This is just (F) example that I used in my project so don't ask in different way. Just wrote in your way of imagination.

### Create Printer Setting Storage

```javascript
// Filename: src/db/index.js

import Dexie from 'dexie';

export const db = new Dexie('YOUR-STORAGE-NAME');

db.version(1).stores({
    printer_settings: '++id, app_name, printer_name, interface, font_type, ch_line_1, ch_line_2, ch_line_3, cf_line_1, cf_line_2, cf_line_3, cl_operator, cl_time, cl_trx_number, cl_costumer_name, cl_discount, cl_total_discount, cl_total, cl_tax, cl_member, cl_paid, cl_return, cl_loan, cl_saving, cl_tempo, line_feed_each_in_items, column_width, more_new_line, pull_cash_drawer, template'
});

```

### Create Printer Setting Page

Create printer setting page to allow user setting their own language and how receipt will look like.

The printer setting page contains field like in Dexie Storage definition.

```javascript
// Filename: src/constants/index.js
// NOTE:
// - cl = Custom Language
// - ch = Custom Header
// - cf = Custom Footer

export const DEFAULT_PRINTER_SETTING = {
    app_name: 'Your Application Name',
    printer_name: '/dev/usb/lp0',
    interface: 'linux-usb',
    font_type: 'C',
    ch_line_1: 'Your Header Line 1',
    ch_line_2: 'Your Header Line 2',
    ch_line_3: 'Your Header Line 3',
    cf_line_1: 'Your Footer Line 1',
    cf_line_2: 'Your Footer Line 2',
    cf_line_3: 'Your Footer Line 3',
    cl_operator: 'Operator Name',
    cl_time: 'Time',
    cl_trx_number: 'Transaction Number',
    cl_customer_name: 'Customer Name',
    cl_discount: 'Discount',
    cl_total_discount: 'Total Discount',
    cl_total: 'Total',
    cl_tax: 'Tax',
    cl_member: 'Member',
    cl_paid: 'Paid',
    cl_return: 'Return',
    cl_debt: 'Debt',
    cl_saving: 'Saving',
    cl_due_date: 'Due date',
    line_feed_each_in_items: 15,
    column_width: 40,
    more_new_line: 1,
    pull_cash_drawer: 'no',
    template: 'epson'
}

const interfaceOptions = [
    { value: 'cpus', key: 'CPUS' },
    { value: 'ethernet', key: 'Ethernet' },
    { value: 'linux-usb', key: 'Linux USB' },
    { value: 'smb', key: 'SMB' },
    { value: 'windows-usb', key: 'Windows USB' },
    { value: 'windows-lpt', key: 'Windows LPT' },
]

const fontTypeOptions = [
    { value: 'A', key: 'Font Type A' },
    { value: 'B', key: 'Font Type B' },
    { value: 'C', key: 'Font Type C' },
]
```

Create form that provide user to input settings. You can create with [Formik](https://formik.org) or something that you know to create form in react.

In my (F) case, I use [Formik](https://formik.org) and make `DEFAULT_PRINTER_SETTING` constant as form `initialValues` and use `interfaceOptions` and `fontTypeOptions` to create dropdown options in form.

### Create Form Submit Handler

As you can see in above, form need to submit to store setting in Dexie Storage.

```javascript
// Filename: src/pages/PrinterSetting.js
import { db } from '../db'
import { DEFAULT_PRINTER_SETTING } from '../constants'

const formOnSubmit = async (values) => {
    try {
        db.printer_settings.count(count => {
            if (count > 0) {
                db.printer_settings.update(1, values).then(updated => {
                    if (updated)
                        console.log("Printer setting was updated!");
                    else
                        console.warn("Nothing was updated - there were no update changed");
                });
            } else {
                db.printer_settings.add(DEFAULT_PRINTER_SETTING).then(() => {
                    console.log("New setting just created using default setting");
                }).catch(error => {
                    console.error(error)
                })
            }
        })
    } catch (error) {
        console.error(error)
    }
}
```

After creating printer setting page, don't forget to drink your coffee and fire your ciggarettes, play next song that you hate so much in loud volume!

### Create POS Context

To make life easy... create POS `Context` and `Provider` in react. Place it and wrap you app on it.

![...For What?](https://i.imgflip.com/72ivf9.jpg)

We need to make WebSocket Client that share it in whole app:
- Share WebSocket Connection Status
- Create Indicator
- Send Message to WebSocket Server

```javascript
// Filename: src/contexts/POSContext.js
import useWebSocket from 'react-use-websocket'
import { db } from '../db'
import { DEFAULT_PRINTER_SETTING } from '../constants'

const POSContext = createContext()

const SOCKET_URL = 'ws://<your-websocket-address:port>'

const generateAsyncUrlGetter = (url, timeout = 2000) => () => {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(url);
        }, timeout);
    });
};

const POSProvider = ({ children }) => {
    const [currentSocketUrl, setCurrentSocketUrl] = useState(SOCKET_URL)
    const [printerSettings, setprinterSettings] = useState()

    const { sendMessage, sendJsonMessage, readyState } = useWebSocket(
        currentSocketUrl,
        {
            share: true,
            shouldReconnect: () => true,
            reconnectAttempts: 3,
            reconnectInterval: 3000,
            onError: (event) => {
                alert.show('WebSocket trying to connect to sever but failed!', {
                    type: 'error',
                    position: 'bottom right',
                    timeout: 3000
                })
            },
            onOpen: (event) => {
                alert.show('WebSocket connection establised!', {
                    type: 'success',
                    position: 'bottom right',
                    timeout: 3000
                })
            },
            onClose: (event) => {
                alert.show('WebSocket connection is closed!', {
                    type: 'warning',
                    position: 'bottom right',
                    timeout: 3000
                })
            },
            onReconnectStop: () => 6
        }
    )

    const readyStateString = {
        0: 'CONNECTING',
        1: 'OPEN',
        2: 'CLOSING',
        3: 'CLOSED',
    }[readyState]

    const reconnectWebSocket = useCallback(() => {
        if (readyState === 3) {
            setCurrentSocketUrl(generateAsyncUrlGetter(SOCKET_URL))
        } else {
            setCurrentSocketUrl(null)
        }
    }, [setCurrentSocketUrl, readyState])

    const initData = useCallback(async () => {
        if (readyState === 1) {
            db.printer_settings.count(count => {
                if (count > 0) {
                    setprinterSettings(first(settings))
                    console.log('Setting is exists')
                } else {
                    setprinterSettings(DEFAULT_PRINTER_SETTING)
                    console.log('Setting is not exists!')
                }
            })
        } else {
            console.log('Setting is not exists!')
            setprinterSettings(DEFAULT_PRINTER_SETTING)
        }
    }, [readyState])

    useEffect(() => {
        if (readyState === 1) initData()
    }, [readyState, initData])

    return (
        <KasirContext.Provider value={{
            sendMessage,
            sendJsonMessage,
            reconnectWebSocket,
            readyStateString,
            printerSettings
        }}>
            {children}
        </KasirContext.Provider>
    )
}

export { KasirContext, KasirProvider }
```

Drink coffee and smoke! More loud volume!

![Dance More](https://media.tenor.com/beX7hfl-LHEAAAAd/indian-dance.gif)

### Create Indicator Button

This button showing WebSocket connection between Client and Server. This button also can trigger to reconnect to server when connection is closed.

```javascript
// Filename: src/components/IndicatorButton.js

import React, { useContext } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { POSContext } from '../contexts/POSContext';

function IndicatorButton() {
    const posCtx = useContext(POSContext)

    return (
        <button
            className='btn btn-indicator'
            onClick={() => posCtx.reconnectWebSocket()}
        >
            <FontAwesomeIcon icon="fa-solid fa-satellite-dish" />{' '}
            Printer Server is:{' '}
            <span
                className={`${posCtx.readyStateString === 'CONNECTING' ? 'text-info' : (posCtx.readyStateString === 'OPEN') ? 'text-success' : (posCtx.readyStateString === 'CLOSED') ? 'text-secondary' : 'text-danger'}`}
            >
                {posCtx.readyStateString}
            </span>
        </button>
    )
}

export default IndicatorButton
```

You can place it that component somewhere in you code.

Now! The WebSocket Client is running but connection has been close for 3 times attempt reconnecting to WebSocket Server.

So what next?!

### Create WebSocket Server

Creating websocket server using `workerman` is like magic.

1. Create new PHP `index.php` file inside `printer-server` directory
2. Visit [Workerman Repository](https://github.com/walkor/workerman)
3. Install `workerman` and `escpos-php` package inside `printer-server` directory using `npm` (that's dumb!)
4. copy and paste [A simple websocket example](https://github.com/walkor/workerman#a-websocket-server)
5. go to your bus terminal and run `php index.php start`

That's it, nothing else! and you can't do any printing call with that code. It's easy right?

![WTF! Are you kidding me?!](https://media.tenor.com/Sk9gjGqIVEkAAAAC/good-morning.gif)
_WTF! Are you kidding me?! (That's sound great BTW. Thank you)_

### Don't mad me! Just Kidding Please!

Now update websocket server script with this code! I promise you... (My head sound: $^U@%G@YT@#.....) 

But before you touch websocket server script you need to know _**what data that printer server needed?**_

> Am Indonesian so don't mad me if my source code contains any Indonesian Language that mixed with English Language. You can translate Indonesia > English using Translator btw or after that you can learn more Indonesian Language _(Hahaha just kidding! Next!)_

#### Payload That WebSocket Server (Printer Server Needed)

```json
{
	"trx": {
		"nama_kasir": "Umam",
		"no_transaksi": "nomor-transaksi",
		"trx": "only-number-trx",
		"nama_pelanggan": "nama-pelanggan",
		"tgl_transaksi": "tanggal-transaksi-format-yyyy-mm-dd",
		"diskon": "jumlah-diskon",
		"total_transaksi": "total-transaksi",
		"uang_diterima": "uang-diterima",
		"uang_kembali": "uang-kembali",
		"jenis_pembayaran": "jenis-pembayaran",
		"jatuh_tempo": "tanggal-jatuh-tempo",
		"uang_titip": "jumlah-uang-titip",
		"ekspedisi": null,
		"ongkir": null,
		"pajak": "0",
		"potongan_member": "0"
	},
	"text_kembalian": 0,
	"belanja": [
		{
			"barang_nama": "MIE SEDAP SOTO",
			"harga": "0",
			"jumlah": "50 Dus",
			"sub_total": "5125000",
			"initial_code": "BRG-605A5B07C3EE5",
			"no_transaksi": "nomor-transaksi"
		},
		{
			"barang_nama": "MIE SEDAP GORENG",
			"harga": "107000",
			"jumlah": "38 Dus",
			"sub_total": "4066000",
			"initial_code": "BRG-605A5B01530C4",
			"no_transaksi": "nomor-transaksi"
		},
		{
			"barang_nama": "PATI SG",
			"harga": "0",
			"jumlah": "100 Sak",
			"sub_total": "25450000",
			"initial_code": "BRG-625678D10EC80",
			"no_transaksi": "nomor-transaksi"
		}
	],
	"alamat_gudang": "Jl. AHMAD YANI - DESA SAMBIROTO - TAYU - PATI <br>\r\nDepan Masjid Baitussalam <br>\r\n0853 - 2622 - 5333 / 0856 - 4165 - 5202",
	"app_name": "CV. Kalimasodo Agriculture",
	"nama_lengkap": "Umam",
	"from": "posclient",
	"command": "posclient",
	"printer_name": "EPSON TM-U220 Receipt",
	"printer_settings": {
		"printer_name": "/dev/usb/lp0",
		"interface": "linux-usb",
		"font_type": "A",
		"custom_print_header": [
			"Your Company Name",
			"Your Subheading for Receipt",
			"Your Third text on header"
		],
		"custom_print_footer": [
			"Your Company Name",
			"Your Subheading for Receipt",
			"Your Third text on footer"
		],
		"custom_language": {
			"operator": "Operator",
			"time": "Time",
			"trx_number": "TRX Number",
			"costumer_name": "Costumer",
			"discount": "Discount",
			"total_discount": "Total Disc" ,
			"total": "Total",
			"tax": "Tax",
			"member": "Member",
			"paid": "Paid",
			"return": "Return",
			"loan": "Loan",
			"saving": "Saving",
			"tempo": "Tempo"
		},
		"line_feed_each_in_items": 1,
		"column_width": 48,
		"more_new_line": 1,
		"pull_cash_drawer": false
	}
}
```

You can format that request payload as you want with that schema.

### Create WebSocket Printer Server

Following is all the code in `index.php`

```php
<?php

use Workerman\Worker;
use Mike42\Escpos\Printer;
use Mike42\Escpos\PrintConnectors\DummyPrintConnector;
use Mike42\Escpos\PrintConnectors\CupsPrintConnector;
use Mike42\Escpos\PrintConnectors\NetworkPrintConnector;
use Mike42\Escpos\PrintConnectors\FilePrintConnector;
use Mike42\Escpos\PrintConnectors\WindowsPrintConnector;
use Mike42\Escpos\CapabilityProfile;

require_once __DIR__ . '/vendor/autoload.php';

// Create a Websocket server
$ws_worker = new Worker('websocket://0.0.0.0:2112');

// Emitted when new connection come
$ws_worker->onConnect = function ($connection) {
    echo "New connection\n";
};

// Emitted when data received
$ws_worker->onMessage = function ($connection, $payload) {
    // Send hello $data
    $data = json_decode($payload, TRUE);
    $options = $data['printer_settings'];

    echo '> metadata: ' . $data['from'] . PHP_EOL;
    sendingMessage($connection, createMessage('info', 'metadata: ' . $data['from']));
    echo '> activer_printer: ' . $data['printer_name'] . PHP_EOL;
    sendingMessage($connection, createMessage('info', 'active_printer: ' . $data['printer_name']));
    echo '< echo', "\n";
    print_r($options);

    if ($data['from'] === 'posclient') {
        print_reciept($data, $options);
    } else if ($data['from'] === 'testprinter') {
    }
};

// Emitted when connection closed
$ws_worker->onClose = function ($connection) {
    echo "Connection closed\n";
};

// Run worker
Worker::runAll();

function print_reciept($arrData, $options = null)
{
    // Printer Connector
    $allowed_print_interfaces = ['cpus', 'ethernet', 'linux-usb', 'smb', 'windows-usb', 'windows-lpt'];
    if (in_array($options['interface'], $allowed_print_interfaces)) {
        switch ($options['interface']) {
            case 'cpus':
                $connector = new CupsPrintConnector($options['printer_name']);
                break;
            case 'ethernet':
                $connector = new NetworkPrintConnector($options['printer_name'], 9100);
                break;
            case 'linux-usb':
                $connector = new FilePrintConnector($options['printer_name']);
                break;
            case 'smb':
            case 'windows-usb':
            case 'windows-lpt':
            default:
                $connector = new WindowsPrintConnector($options['printer_name']);
                break;
        }
    } else {
        // Make sure you load a Star print connector or you may get gibberish.
        $connector = new DummyPrintConnector();
        $profile = CapabilityProfile::load("TSP600");
    }

    // Font Type
    $selectPrinterFont = [
        'A' => Printer::FONT_A,
        'B' => Printer::FONT_B,
        'C' => Printer::FONT_C
    ];

    if ($options['template'] === 'epson') {
        $fontSettings = [
            'header_nota' => $selectPrinterFont['B'],
            'sub_header_nota' => $selectPrinterFont['B'],
            'detail_operator' => $selectPrinterFont['C'],
            'detail_balanja' => $selectPrinterFont['C'],
            'sub_promo' => $selectPrinterFont['B'],
            'footer' => $selectPrinterFont['C'],
            'sub_footer' => $selectPrinterFont['B']
        ];
    } else {
        $fontSettings = [
            'header_nota' => $selectPrinterFont['C'],
            'sub_header_nota' => $selectPrinterFont['C'],
            'detail_operator' => $selectPrinterFont['C'],
            'detail_balanja' => $selectPrinterFont['C'],
            'sub_promo' => $selectPrinterFont['C'],
            'footer' => $selectPrinterFont['C'],
            'sub_footer' => $selectPrinterFont['C']
        ];
    }

    echo "Line feed each item: " . $options['line_feed_each_in_items'];

    $printer = new Printer($connector);
    $column_width = ($options['template'] === 'epson') ? 40 : 48;
    $divider    = str_repeat("-", $column_width) . "\n";
    $optnl = ($options['template'] === 'epson') ? 0 : $options['more_new_line'];
    $more_new_line = str_repeat("\n", $optnl);

    // Membuat header nota
    $printer->initialize();
    $printer->selectPrintMode(Printer::MODE_DOUBLE_HEIGHT); // Setting teks menjadi lebih besar
    $printer->setJustification(Printer::JUSTIFY_CENTER);    // Setting teks menjadi rata tengah
    $printer->setFont($fontSettings['header_nota']);
    $printer->setEmphasis(true);
    $printer->text($arrData['app_name'] . "\n");
    $printer->setEmphasis(false);
    $printer->feed(1);

    // Membuat sub header nota
    $printer->initialize();
    $printer->setJustification(Printer::JUSTIFY_CENTER); // Setting teks menjadi rata tengah
    $printer->setFont($fontSettings['sub_header_nota']);
    $printer->setEmphasis(true);
    if (count($options['custom_print_header']) > 0) {
        for ($i = 0; $i < count($options['custom_print_header']); $i++) {
            $header_text = str_replace('\r\n', "", $options['custom_print_header'][$i]);
            $header_text = str_replace('<br>', PHP_EOL, $header_text);
            $printer->text(strtoupper($header_text) . "\n");
        }
    } else {
        $printer->text("MELAYANI SEPENUH HATI\n");
        $printer->text(strtoupper(str_replace("<br>", "", $arrData['alamat_gudang'])) . "\n");
        $printer->text("Buka Jam 07:30 - 16:30\n");
    }
    $printer->setEmphasis(false);
    $printer->feed(1);

    // Membuat detail operator
    $printer->initialize();
    $printer->setLineSpacing(20);
    $printer->setFont($fontSettings['detail_operator']);
    $printer->setEmphasis(true);
    $printer->text($divider);
    $printer->text(buatBarisSejajar($options['custom_language']['operator'] . " : ", $arrData['nama_lengkap'] . $more_new_line, $column_width));
    $printer->text(buatBarisSejajar($options['custom_language']['time'] . " : ", $arrData['trx']['tgl_transaksi'] . ' ' . date('H:i:s')  . $more_new_line, $column_width));
    $printer->text(buatBarisSejajar($options['custom_language']['trx_number'] . " : ", $arrData['trx']['trx'] . $more_new_line, $column_width));
    $printer->text(buatBarisSejajar($options['custom_language']['costumer_name'] . " : ", $arrData['trx']['nama_pelanggan'] . $more_new_line, $column_width));
    $printer->text($divider);
    $printer->setEmphasis(false);
    $printer->feed(1);

    // Print Detail Belanja
    $printer->initialize();
    $printer->setLineSpacing(20);
    $printer->setFont($fontSettings['detail_balanja']);
    $printer->setEmphasis(true);
    $no    = 1;
    foreach ($arrData['belanja'] as $b) {
        $printer->text(buatBarisContent($b['barang_nama'], idr_format($b['sub_total'], false), $options['template']));
        $printer->text('@ ' . idr_format($b['harga'], true) . " x " . $b['jumlah'] . "\n");
        $printer->feed($options['line_feed_each_in_items']);
        $no++;
    }
    $printer->setEmphasis(false);
    $printer->feed(1);

    if (count($arrData['promo']) > 0) {
        // Membuat sub promo
        $printer->initialize();
        $printer->setLineSpacing(20);
        $printer->feed(1);
        $printer->setJustification(Printer::JUSTIFY_CENTER); // Setting teks menjadi rata tengah
        $printer->setFont($fontSettings['sub_promo']);
        $printer->setEmphasis(true);
        $printer->text($divider);
        for ($i = 0; $i < count($arrData['promo']); $i++) {
            $printer->text(wordwrap($arrData['promo'][$i], 35, "\n") . "\n\n");
        }
        $printer->setEmphasis(false);
        $printer->feed(3);
    }

    // Print Footer
    $printer->initialize();
    $printer->setLineSpacing(20);
    $printer->setFont($fontSettings['footer']);
    $printer->setEmphasis(true);
    $printer->text($divider);

    // Total Belanja
    if ($arrData['trx']['sebelum_diskon'] != $arrData['trx']['total_transaksi']) {
        $printer->text(buatBarisSejajar('Total Belanja', idr_format($arrData['trx']['sebelum_diskon']), $column_width) . $more_new_line);
    }

    // Total Pajak
    if ($arrData['trx']['pajak'] != 0) {
        $printer->text(buatBarisSejajar($options['custom_language']['tax'], idr_format($arrData['trx']['pajak']), $column_width) . $more_new_line);
    }
    // Total Diskon
    if ($arrData['trx']['diskon'] != 0) {
        $printer->text(buatBarisSejajar($options['custom_language']['discount'], idr_format($arrData['trx']['diskon']), $column_width) . $more_new_line);
    }
    // Total Potongan Member
    if ($arrData['trx']['potongan_member'] != 0) {
        $printer->text(buatBarisSejajar($options['custom_language']['member'], $arrData['trx']['potongan_member'], $column_width) . $more_new_line);
    }

    $printer->text(buatBarisSejajar($options['custom_language']['total'], idr_format($arrData['trx']['total_transaksi']), $column_width) . $more_new_line);

    // Total Bayar
    $bayar = ($arrData['trx']['uang_diterima'] != 0) ? number_format($arrData['trx']['uang_diterima']) : '-';
    $printer->text(buatBarisSejajar($options['custom_language']['paid'], $bayar, $column_width) . $more_new_line);

    $text_kembalian = ($arrData['trx']['jenis_pembayaran'] != 'Non Tunai') ? number_format($arrData['trx']['uang_kembali']) : '-';

    $printer->text(buatBarisSejajar($options['custom_language']['return'], $text_kembalian, $column_width) . $more_new_line);
    // End Total Kembalian
    if ($arrData['trx']['jenis_pembayaran'] == 'Non Tunai') {
        $printer->text(buatBarisSejajar($options['custom_language']['tempo'], date('d/m/Y', strtotime($arrData['trx']['jatuh_tempo'])), $column_width) . $more_new_line);
        $printer->text(buatBarisSejajar($options['custom_language']['saving'], number_format($arrData['trx']['uang_titip']), $column_width) . $more_new_line);
        $printer->text(buatBarisSejajar($options['custom_language']['loan'], number_format($arrData['trx']['total_transaksi'] - $arrData['trx']['uang_titip']), $column_width) . $more_new_line);
    }
    $printer->setEmphasis(false);
    $printer->feed(1);

    // Membuat sub footer nota
    $printer->initialize();
    // $printer->setLineSpacing(20);
    $printer->feed(1);
    $printer->setJustification(Printer::JUSTIFY_CENTER); // Setting teks menjadi rata tengah
    $printer->setFont($fontSettings['sub_footer']);
    $printer->setEmphasis(true);
    if (count($options['custom_print_footer']) > 0) {
        $printer->text("Terimakasih sudah berbelanja di\n");
        for ($i = 0; $i < count($options['custom_print_footer']); $i++) {
            $printer->text($options['custom_print_footer'][$i] . "\n");
        }
    } else {
        $printer->text("Terimakasih sudah berbelanja di\n");
        $printer->text("CV. Kalimosodo Angriculture\n");
        $printer->text("Barang yang sudah dibeli tidak bisa\n");
        $printer->text("dikembalikan.\n");
    }
    $printer->setEmphasis(false);
    $printer->feed(3);

    $printer->cut();

    // Get the data out as a string
    // $data = $connector -> getData();
    // echo $data . PHP_EOL;

    /* Pulse */
    if ($options['pull_cash_drawer']) {
        $printer->pulse();
    }

    // Close the printer when done.
    $printer->close();
}

function sendingMessage($connection, $message)
{
    $connection->send($message);
}

function createMessage($type = 'info', $message)
{
    $timestamps = date('Y/m/d H:i:s');
    $log = strtoupper($type);
    return "[$timestamps][$log] $message";
}

function buatBarisSejajar($kolom1, $kolom2, $x)
{
    $divider    = str_repeat("-", $x);
    $full_width = strlen($divider);
    $half_width = $full_width / 2;

    $rawText = str_pad($kolom1, $half_width);
    $rawText .= str_pad($kolom2, $half_width, " ", STR_PAD_LEFT);
    return $rawText . "\n";
}

function buatBarisContent($kolom1, $kolom2, $template)
{
    $paperLength   = ($template === 'epson') ? 40 : 48;
    $divider       = str_repeat("-", $paperLength);
    $full_width    = strlen($divider);
    $half_width    = $full_width / 2;
    $total_x1 = ($template === 'epson') ? 18 : 22;
    $total_x2 = ($template === 'epson') ? 21 : 25;
    $lebar_kolom_1 = $total_x1; // asale 18
    $lebar_kolom_2 = $total_x2; // asale 21

    // Melakukan wordwrap(), jadi jika karakter teks melebihi lebar kolom, ditambahkan \n
    $kolom1 = wordwrap($kolom1, $lebar_kolom_1, "\n", true);
    $kolom2 = wordwrap($kolom2, $lebar_kolom_2, "\n", true);

    // Merubah hasil wordwrap menjadi array, kolom yang memiliki 2 index array berarti memiliki 2 baris (kena wordwrap)
    $kolom1Array = explode("\n", $kolom1);
    $kolom2Array = explode("\n", $kolom2);

    // Mengambil jumlah baris terbanyak dari kolom-kolom untuk dijadikan titik akhir perulangan
    $jmlBarisTerbanyak = max(count($kolom1Array), count($kolom2Array));

    // Mendeklarasikan variabel untuk menampung kolom yang sudah di edit
    $hasilBaris = array();

    // Melakukan perulangan setiap baris (yang dibentuk wordwrap), untuk menggabungkan setiap kolom menjadi 1 baris
    for ($i = 0; $i < $jmlBarisTerbanyak; $i++) {

        // memberikan spasi di setiap cell berdasarkan lebar kolom yang ditentukan,
        $hasilKolom1 = str_pad((isset($kolom1Array[$i]) ? $kolom1Array[$i] : ""), $lebar_kolom_1);
        $hasilKolom2 = str_pad((isset($kolom2Array[$i]) ? $kolom2Array[$i] : ""), $lebar_kolom_2, " ", STR_PAD_LEFT);

        // Menggabungkan kolom tersebut menjadi 1 baris dan ditampung ke variabel hasil (ada 1 spasi disetiap kolom)
        $hasilBaris[] = $hasilKolom1 . " " . $hasilKolom2;
    }

    // Hasil yang berupa array, disatukan kembali menjadi string dan tambahkan \n disetiap barisnya.
    return implode("\n", $hasilBaris) . "\n";
}

function idr_format($number, $prefix = false, $decimal_number = 0, $decimal_point = ',', $thousand_point = '.')
{
    return ($prefix == false) ? number_format($number, $decimal_number, $decimal_point, $thousand_point) : 'Rp. ' . number_format($number, $decimal_number, $decimal_point, $thousand_point);
}
```

To run the above code we need to use the following command in the command line, with this command:

```bash
➜ php index.php start
Workerman[index.php] start in DEBUG mode
------------------------------------------- WORKERMAN --------------------------------------------
Workerman version:4.1.4          PHP version:7.4.33           Event-Loop:\Workerman\Events\Select
-------------------------------------------- WORKERS ---------------------------------------------
proto   user            worker          listen                      processes    status           
tcp     darkterminal    none            websocket://0.0.0.0:2112    1             [OK]            
--------------------------------------------------------------------------------------------------
Press Ctrl+C to stop. Start success.
```

If you want something more efficient and don't repeat the command above, please use `supervisord`, `systemd`, or `nssm` ([NSSM - the Non-Sucking Service Manager](https://nssm.cc/download) for Windows users)

---

If you find this blog is useful and open your mind to another side of the world of IT and want to give me a cup of coffee or become a sponsor on my GitHub account:

{% cta https://paypal.me/lazarusalhambra %} Buy Me ☕ {% endcta %}  {% cta https://github.com/sponsors/darkterminal %} Become Sponsor ❤ {% endcta %}

For sponsors/donors, your name will be included in the upcoming article, so please make sure to provide your username from your DEV.to, Twitter, Github, or any other social media accounts you have.

{% cta https://github.com/darkterminal/darkterminal/discussions/categories/q-a %} Ask Me Anything 💬 {% endcta %}