ร้านที่รับโอนเงินแล้วให้ลูกค้าส่งสลิปเข้ามา พอสลิปเยอะขึ้น พนักงานก็เปิดแอปธนาคารไล่เช็กทีละใบไม่ไหว SlipOK เป็นบริการตรวจสลิปของไทยที่ช่วยตรงนี้ได้ เราส่งรูปสลิปไป SlipOK อ่าน QR มุมสลิปแล้วไปถามรายการจากธนาคาร ตอบกลับมาว่ารายการนี้มีจริงไหม ยอดเท่าไหร่ โอนเข้าบัญชีไหน
โพสต์นี้ทำระบบตรวจสลิปใน Laravel ตั้งแต่ลูกค้าอัปโหลดจนพนักงานกดใช้สลิป ใช้ได้กับระบบไหนก็ได้ที่รับสลิป ตัวอย่างทั้งหมดใช้ข้อมูลสมมติ
SlipOK ตอบอะไรกลับมาบ้าง
ใช้ API ของ SlipOK ต้องมี API key กับ branch ID ของร้าน แล้ว POST ไปที่ https://api.slipok.com/api/line/apikey/<branch ID> พร้อม header x-authorization ตามเอกสาร Check Slip ถ้าสลิปตรวจผ่าน จะได้คำตอบหน้าตาแบบนี้ (ตัดมาเฉพาะช่องที่เราใช้)
{
"success": true,
"data": {
"transRef": "016284105246ATF07812",
"transTimestamp": "2026-10-01T04:52:46.000Z",
"amount": 1000,
"sender": {
"displayName": "นาย ก ตัวอย่าง",
"account": { "type": "BANKAC", "value": "xxx-x-x5678-x" }
},
"receiver": {
"displayName": "ร้านตัวอย่าง",
"account": { "type": "BANKAC", "value": "xxx-x-x1234-x" }
}
}
}
transRefเลขอ้างอิงรายการโอน สลิปจริงใบหนึ่งมีเลขเดียว เราใช้เช็กสลิปซ้ำamountกับtransTimestampยอดเงินและเวลาโอน (เป็น UTC)receiverบัญชีที่รับเงิน เลขบัญชีปิดไว้บางหลักเสมอ เราใช้เช็กว่าเงินเข้าบัญชีของร้าน
ถ้าตรวจไม่ผ่าน จะได้ success: false พร้อม code เป็นตัวเลข เช่น 1007 แปลว่าในรูปไม่มี QR ส่วนรหัสไหนควรทำอะไรต่อ อยู่ในหัวข้อจัดการคำตอบตามรหัส error ด้านล่าง
ลำดับการตรวจ ตั้งแต่อัปโหลดจนพนักงานกดใช้สลิป
flowchart TD
A[ลูกค้าอัปโหลดสลิป] --> B[บันทึกสลิป แล้วรอ 60 วินาที]
B --> C[job CheckSlip ถาม SlipOK]
C -->|ผ่าน| D{เลขอ้างอิงซ้ำ หรือบัญชีผู้รับไม่ตรง}
C -->|1009 / 1010 / 1011| E[รอรอบถัดไป ทุก 5 นาที]
E --> C
E -->|รอเกิน 24 ชั่วโมง| F[คิวให้พนักงานดู]
C -->|1005 ถึง 1008| F
D -->|ไม่ซ้ำ บัญชีตรง| G[รอพนักงานกดใช้สลิป]
D -->|ซ้ำ หรือไม่ตรง| F
F --> H[พนักงานตัดสิน ใช้ หรือ ปฏิเสธ]
G --> Hทุกทางจบที่พนักงาน ระบบแค่จัดว่าสลิปใบไหนตรวจผ่านแล้ว ใบไหนต้องดูเพิ่ม
เก็บผลตรวจแยกจากการตัดสินของพนักงาน
สลิปหนึ่งใบมีสองคำถามที่ต่างกัน คือ SlipOK ว่ายังไง กับพนักงานตัดสินว่ายังไง ถ้าเก็บรวมในคอลัมน์เดียว โค้ดตรวจสลิปก็เขียนทับการตัดสินของคนได้ง่าย เราจึงแยกเป็นสองคอลัมน์
<?php
namespace App\Enums;
enum SlipCheckStatus: string
{
case Waiting = 'waiting';
case Verified = 'verified';
case NeedsReview = 'needs_review';
case Duplicate = 'duplicate';
case WrongReceiver = 'wrong_receiver';
}
<?php
namespace App\Enums;
enum SlipDecision: string
{
case Used = 'used';
case Rejected = 'rejected';
}
Schema::create('slips', function (Blueprint $table) {
$table->id();
$table->foreignId('customer_id')->constrained();
$table->string('path');
$table->string('check_status')->default('waiting');
$table->string('decision')->nullable();
$table->string('trans_ref')->nullable()->index();
$table->decimal('amount', 12, 2)->nullable();
$table->timestamp('transferred_at')->nullable();
$table->unsignedSmallInteger('error_code')->nullable();
$table->string('error_message')->nullable();
$table->timestamps();
});
check_status เป็นของระบบ job ตรวจสลิปเขียนได้คอลัมน์นี้คอลัมน์เดียว ส่วน decision ว่างไว้จนกว่าพนักงานจะกดใช้สลิป (used) หรือปฏิเสธ (rejected) หน้าคิวของพนักงานแสดงทุกใบที่ decision ยังว่าง พร้อมป้ายบอก check_status ใบที่ตรวจผ่านแล้วกดใช้ได้เลย ใบที่ขึ้นว่าต้องดูเพิ่มก็เปิดรูปดูก่อนตัดสิน
ส่งรูปสลิปให้ SlipOK และเก็บ log ทุกครั้ง
SlipOK รับสลิปได้สามแบบตามเอกสาร Check Slip คือข้อความใน QR (data) ไฟล์รูป (files) หรือ URL ของรูป (url) เราเลือกส่งไฟล์ เพราะถ้าส่ง URL รูปสลิปต้องเปิดได้จากอินเทอร์เน็ตโดยไม่ต้อง login ขณะที่สลิปมีชื่อลูกค้าและเลขบัญชีอยู่ เราเก็บรูปไว้ใน disk ส่วนตัวชื่อ slips ที่ไม่ผูกกับ public
'slipok' => [
'api_key' => env('SLIPOK_API_KEY'),
'branch_id' => env('SLIPOK_BRANCH_ID'),
],
<?php
namespace App\Services;
use App\Models\Slip;
use App\Models\SlipOkLog;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class SlipOk
{
/**
* @return array<string, mixed>
*/
public function check(Slip $slip): array
{
Context::add('slip_id', $slip->id);
$startedAt = hrtime(true);
try {
$response = Http::timeout(20)
->withHeader('x-authorization', config('services.slipok.api_key'))
->attach('files', Storage::disk('slips')->get($slip->path), basename($slip->path))
->post('https://api.slipok.com/api/line/apikey/'.config('services.slipok.branch_id'));
} catch (ConnectionException $exception) {
$this->log($slip, null, ['exception' => $exception->getMessage()], $startedAt);
throw $exception;
}
$payload = $response->json() ?? ['raw' => Str::limit($response->body(), 2000)];
$this->log($slip, $response->status(), $payload, $startedAt);
return $payload;
}
/**
* @param array<string, mixed> $payload
*/
private function log(Slip $slip, ?int $httpStatus, array $payload, int $startedAt): void
{
SlipOkLog::create([
'slip_id' => $slip->id,
'http_status' => $httpStatus,
'payload' => $payload,
'milliseconds' => intdiv(hrtime(true) - $startedAt, 1_000_000),
]);
}
}
SlipOkLog เป็น model ธรรมดาที่ cast payload เป็น array เราเก็บคำตอบดิบของทุกครั้งที่เรียก ทั้งที่ผ่าน ที่ได้ error และที่ต่อไม่ติด แต่ไม่เก็บ API key วันที่ลูกค้าถามว่า "โอนไปตั้งแต่เช้า ทำไมยังไม่เข้า" เราเปิดดูได้ทันทีว่าสลิปใบนั้นถาม SlipOK ไปกี่ครั้ง ตอนไหน ได้คำตอบอะไร Context::add('slip_id', …) ช่วยให้ log บรรทัดอื่นที่เขียนระหว่างตรวจสลิปใบนี้มี slip_id ติดไปด้วย รายละเอียดอยู่ใน Laravel Context คืออะไร
ถ้าเจอ error เรื่อง SSL ตอนเรียก API ในเครื่อง อย่าแก้ด้วย Http::withoutVerifying() เพราะ request นี้มี API key อยู่ใน header ปิดการเช็กใบรับรองเมื่อไหร่ ใครที่ดักกลางทางได้ก็อ่าน key ไปได้ ให้แก้ที่ CA bundle ของ PHP ในเครื่องแทน
ตรวจสลิปใน queue หลังลูกค้าอัปโหลด
ตอนลูกค้าอัปโหลด เราบันทึกสลิปแล้ว dispatch job ตรวจสลิปแบบหน่วงไว้ 60 วินาที ลูกค้าเห็นข้อความว่าร้านได้รับสลิปแล้วทันที ไม่ต้องรอผลตรวจ
public function store(StoreSlipRequest $request): RedirectResponse
{
$slip = $request->user()->slips()->create([
'path' => $request->file('slip')->store('uploads', 'slips'),
]);
CheckSlip::dispatch($slip)->delay(now()->addMinute());
return back()->with('status', 'ร้านได้รับสลิปแล้ว จะตรวจและยืนยันให้เร็วที่สุด');
}
queue คือที่พักงานที่ไม่ต้องทำเสร็จภายใน request นี้ แล้วให้ worker ที่รันแยกอยู่หยิบไปทำทีหลัง delay() ใช้ได้กับ queue แบบ database และ redis แต่ถ้า QUEUE_CONNECTION=sync job จะรันทันทีโดยไม่รอ ถ้าสร้างสลิปใน DB::transaction() ให้ job รอ commit ก่อนด้วย ดูวิธีได้ใน สั่ง Job ให้รอ Database Transaction commit ก่อน
สลิปที่เพิ่งโอน SlipOK อาจตอบว่าไม่มีรายการ
ที่หน่วงไว้ 60 วินาที ก็เพราะสลิปที่เพิ่งโอนมักยังตรวจไม่ผ่าน รหัส 1011 ในเอกสาร Error Status Code ของ SlipOK มีข้อความว่า "QR Code หมดอายุ หรือ ไม่มีรายการอยู่จริง" ในทางปฏิบัติ รายการโอนข้ามธนาคารที่ยังไม่เข้าระบบที่ SlipOK ไปถามก็ได้รหัสนี้เหมือนกัน ลูกค้ามักโอนเสร็จแล้วอัปโหลดสลิปภายในไม่กี่วินาที ถ้าเราถาม SlipOK ทันที คำตอบที่ได้ก็มักเป็น 1011 ทั้งที่อีกสักครู่ตรวจซ้ำก็ผ่าน
จาก log ของระบบที่เราดูแล สลิปที่ตรวจผ่านเร็วที่สุดมีอายุราว 10 วินาทีนับจากเวลาโอน สลิปที่อายุไม่ถึง 15 วินาทีแทบไม่ผ่านเลย และครึ่งหนึ่งของสลิปที่ผ่านมีอายุเกิน 39 วินาทีตอนตรวจ
ถ้าระบบเห็น 1011 แล้วปฏิเสธสลิปทันที จะเกิดสองเรื่องต่อกัน
- สลิปจริงออกจากคิวรอตรวจ พนักงานไม่เห็น ลูกค้าโอนแล้วแต่ยอดไม่เข้า
- พอลูกค้าส่งสลิปใบเดิมมาใหม่ ระบบเห็นเลขอ้างอิงตรงกับสลิปที่เคยรับไว้ จึงขึ้นว่าสลิปซ้ำ ลูกค้าติดอยู่ตรงนั้นจนกว่าจะโทรมาถามร้าน
เราจึงวางหลักไว้ว่า ระบบยืนยันสลิปที่ผ่านได้ แต่ปฏิเสธเองไม่ได้ ใบไหนยังไม่ผ่าน ให้รอตรวจซ้ำหรือส่งให้คนดู การหน่วง 60 วินาทีไม่ได้กันได้ทุกใบ บางธนาคารต้องรอนานกว่านั้น แต่ช่วยให้สลิปส่วนใหญ่ผ่านตั้งแต่ครั้งแรก ไม่ต้องเสียโควต้าไปกับคำตอบที่รู้อยู่แล้วว่าจะเป็น 1011 ใบที่เหลือรอบตรวจซ้ำทุก 5 นาทีจะเก็บให้
จัดการคำตอบตามรหัส error
รหัส error ของ SlipOK แบ่งได้เป็นสามกลุ่ม คือปัญหาฝั่งเรา สลิปที่ตรวจซ้ำกี่รอบก็ไม่ผ่าน และสลิปที่รอแล้วอาจผ่าน เราจัดไว้แบบนี้
| รหัส | ความหมายตามเอกสาร SlipOK | เราทำอะไร |
|---|---|---|
| 1000–1004 | ส่งข้อมูลไม่ครบ, branch ID หรือ API key ผิด, แพ็กเกจหมดอายุหรือใช้เกินโควต้า | รอรอบถัดไป และเขียน log ระดับ error ให้เรารู้ |
| 1005, 1006 | ไฟล์ไม่ใช่รูป หรือรูปเสีย | ส่งให้พนักงานดู |
| 1007 | ในรูปไม่มี QR Code | ส่งให้พนักงานดู |
| 1008 | QR ในรูปไม่ใช่ QR สำหรับตรวจการชำระเงิน | ส่งให้พนักงานดู |
| 1009 | ข้อมูลธนาคารขัดข้องชั่วคราว ให้ตรวจใหม่ใน 15 นาที (ไม่เสียโควต้า) | ตรวจซ้ำ |
| 1010 | สลิปจากบางธนาคารต้องรอหลังโอนตามจำนวนนาทีที่บอกในข้อความ | ตรวจซ้ำ |
| 1011 | QR หมดอายุ หรือไม่มีรายการ | ตรวจซ้ำ |
| รหัสอื่น หรือไม่มีรหัส | ไม่อยู่ในเอกสาร | ตรวจซ้ำ |
1000–1004 เป็นปัญหาฝั่งเรา สลิปไม่ได้ผิดอะไร จึงไม่ควรไปตัดสินสลิปจากรหัสกลุ่มนี้ ส่วน 1005–1008 ตรวจซ้ำกี่รอบก็ได้ผลเดิม แต่ก็ยังไม่ใช่เหตุให้ปฏิเสธเอง ลูกค้าอาจส่งรูปผิดใบ ส่งรูปที่ครอปจน QR หาย หรือส่งเอกสารอื่นที่ร้านต้องเก็บ พนักงานดูรูปแล้วตัดสินได้ในไม่กี่วินาที ส่วนรหัสที่เราไม่รู้จัก ให้ตรวจซ้ำไว้ก่อน ปลอดภัยกว่าเดาว่าสลิปผิด
<?php
namespace App\Jobs;
use App\Enums\SlipCheckStatus;
use App\Models\ShopAccount;
use App\Models\Slip;
use App\Services\SlipOk;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\Log;
class CheckSlip implements ShouldBeUnique, ShouldQueue
{
use Queueable;
private const array SETUP_ERRORS = [1000, 1001, 1002, 1003, 1004];
private const array NEEDS_HUMAN = [1005, 1006, 1007, 1008];
public function __construct(public Slip $slip) {}
public function uniqueId(): string
{
return (string) $this->slip->id;
}
public function handle(SlipOk $slipOk): void
{
if ($this->slip->decision !== null || $this->slip->check_status !== SlipCheckStatus::Waiting) {
return;
}
$result = $slipOk->check($this->slip);
if (($result['success'] ?? false) !== true) {
$this->recordError((int) ($result['code'] ?? 0), (string) ($result['message'] ?? ''));
return;
}
$this->recordVerified($result['data']);
}
private function recordError(int $code, string $message): void
{
if (in_array($code, self::SETUP_ERRORS, true)) {
Log::error('SlipOK ตอบว่าการตั้งค่าหรือแพ็กเกจมีปัญหา', ['code' => $code, 'message' => $message]);
}
$this->slip->update([
'error_code' => $code,
'error_message' => $message,
'check_status' => in_array($code, self::NEEDS_HUMAN, true)
? SlipCheckStatus::NeedsReview
: SlipCheckStatus::Waiting,
]);
}
/**
* @param array<string, mixed> $data
*/
private function recordVerified(array $data): void
{
$this->slip->update([
'trans_ref' => $data['transRef'],
'amount' => $data['amount'],
'transferred_at' => Date::parse($data['transTimestamp'])->setTimezone(config('app.timezone')),
'error_code' => null,
'error_message' => null,
'check_status' => match (true) {
$this->slip->hasDuplicateOf($data['transRef']) => SlipCheckStatus::Duplicate,
! ShopAccount::receives($data['receiver'] ?? []) => SlipCheckStatus::WrongReceiver,
default => SlipCheckStatus::Verified,
},
]);
}
}
ไม่มีบรรทัดไหนในไฟล์นี้เขียน decision เลย นี่คือสิ่งที่เราตั้งใจ ระบบเปลี่ยนได้แค่ป้ายของผลตรวจ ShouldBeUnique กันไม่ให้มี job ตรวจสลิปใบเดียวกันรอในคิวพร้อมกันสองตัว เช่นตอนที่ job แรกยังหน่วง 60 วินาทีอยู่ แล้วรอบตรวจซ้ำมาถึงพอดี (ใช้ได้กับ cache driver ที่รองรับ lock อย่าง database หรือ redis)
นับสลิปซ้ำเฉพาะใบที่ยังรอหรือใช้ไปแล้ว
transRef คือเลขอ้างอิงรายการโอน สลิปจริงใบหนึ่งมีเลขเดียว ถ้าเจอเลขนี้ในสลิปใบอื่นอยู่แล้ว แปลว่ามีคนส่งสลิปใบเดิมซ้ำ คำถามคือนับ "ใบอื่น" ตัวไหนบ้าง
public function hasDuplicateOf(string $transRef): bool
{
return self::query()
->whereKeyNot($this->id)
->where('trans_ref', $transRef)
->where(fn (Builder $query) => $query
->whereNull('decision')
->orWhere('decision', SlipDecision::Used))
->exists();
}
เรานับแค่สลิปที่ยังรอพนักงาน กับสลิปที่ใช้ไปแล้ว ส่วนสลิปที่พนักงานปฏิเสธไป ไม่นับ เพราะถ้าเคยปฏิเสธสลิปจริงไปด้วยความผิดพลาด ลูกค้าต้องส่งใบเดิมกลับมาได้โดยไม่ขึ้นว่าซ้ำ ถ้าสลิปใบนั้นเป็นของปลอมจริง ใบที่ส่งมาใหม่ก็ยังต้องผ่านพนักงานอยู่ดี
SlipOK เองก็เช็กสลิปซ้ำให้ได้ ถ้าส่ง log: true ไปด้วย จะได้รหัส 1012 เมื่อสลิปนั้นเคยส่งเข้าระบบมาแล้ว แต่เงื่อนไขแบบนั้นนับทุกครั้งที่เคยส่ง ไม่รู้ว่าทางร้านปฏิเสธใบไหนไป เราจึงไม่เปิด และเช็กซ้ำเองในแอป
เช็กว่าเงินเข้าบัญชีของร้านจริง
สลิปจริงที่โอนเข้าบัญชีอื่นก็ผ่าน SlipOK ได้ เช่นสลิปที่ลูกค้าโอนให้ร้านอื่น เราจึงต้องเช็ก receiver ในคำตอบด้วยว่าเป็นบัญชีของเรา เอกสาร SlipOK บอกว่าเลขบัญชีและพร้อมเพย์ในคำตอบจะปิดบางหลักเสมอ เช่น xxx-x-x1234-x และรูปแบบขึ้นกับธนาคาร ส่วนชื่ออาจมาไม่ครบ เราจึงเทียบที่เลข ทีละตำแหน่ง
/**
* @param array{account?: array{value?: string}, proxy?: array{value?: string}} $receiver
*/
public static function receives(array $receiver): bool
{
$maskedNumbers = array_filter([
$receiver['account']['value'] ?? null,
$receiver['proxy']['value'] ?? null,
]);
return self::query()
->where('is_active', true)
->get()
->contains(fn (ShopAccount $account) => collect($maskedNumbers)
->contains(fn (string $masked) => $account->matchesMasked($masked)));
}
public function matchesMasked(string $masked): bool
{
$masked = preg_replace('/[^0-9x]/i', '', $masked);
if (preg_match_all('/\d/', $masked) < 4) {
return false;
}
$pattern = '/^'.str_ireplace('x', '\d', $masked).'$/';
return collect([$this->account_no, $this->promptpay_id])
->filter()
->contains(fn (string $number) => preg_match($pattern, preg_replace('/\D/', '', $number)) === 1);
}
xxx-x-x1234-x กลายเป็น pattern ^\d\d\d\d\d1234\d$ ต้องยาวเท่ากันและเลขที่เห็นต้องอยู่ตำแหน่งเดียวกัน บัญชี 123-4-51234-6 จึงตรง ถ้าเทียบด้วย LIKE '%1234%' บัญชีไหนที่มี 1234 อยู่ตรงไหนก็ได้จะผ่านหมด ส่วนเงื่อนไขขั้นต่ำ 4 หลัก กันกรณีธนาคารปิดเลขทั้งหมด ซึ่งจะทำให้ pattern ตรงกับทุกบัญชี
ถ้าไม่ตรง เราตั้งป้าย WrongReceiver ให้พนักงานดู ไม่ปฏิเสธเอง บางทีร้านเพิ่งเปิดบัญชีใหม่แต่ยังไม่ได้เพิ่มเข้าระบบ หรือธนาคารปิดเลขแบบที่เราไม่เคยเห็น
ถ้ารู้ยอดที่ต้องจ่ายอยู่แล้ว เช่นสลิปของใบสั่งซื้อ ก็เทียบ amount กับยอดของใบสั่งซื้อได้อีกชั้น (SlipOK รับ amount ไปเทียบให้ได้เหมือนกัน ได้รหัส 1013 ถ้าไม่ตรง) แต่ถ้าลูกค้าโอนยอดเท่าไหร่ก็ได้ เช่นเติมเงินเข้าบัญชีสะสม ก็ให้พนักงานดูยอดตอนกดใช้สลิป
ตรวจซ้ำทุก 5 นาที แต่ตั้งเพดานไว้
สลิปที่ยัง Waiting อยู่ ให้ scheduler หยิบมาตรวจใหม่ทุก 5 นาที
use App\Enums\SlipCheckStatus;
use App\Jobs\CheckSlip;
use App\Models\Slip;
use Illuminate\Support\Facades\Schedule;
Schedule::call(function () {
Slip::query()
->whereNull('decision')
->where('check_status', SlipCheckStatus::Waiting)
->each(function (Slip $slip) {
if ($slip->created_at->lt(now()->subDay())) {
$slip->update(['check_status' => SlipCheckStatus::NeedsReview]);
return;
}
CheckSlip::dispatch($slip);
});
})->everyFiveMinutes()->name('recheck-waiting-slips')->withoutOverlapping();
เอกสาร SlipOK บอกไว้แค่ว่า 1009 ไม่เสียโควต้า รหัสอื่นไม่ได้บอก สลิปที่ค้าง 1011 ไปตลอดจะถาม SlipOK วันละ 288 ครั้ง เราจึงตั้งเพดานไว้ที่ 24 ชั่วโมง เกินนั้นส่งเข้าคิวให้พนักงานดูแทน ถ้าร้านมีสลิปเยอะ จะเพิ่มช่วงห่างขึ้นเรื่อย ๆ ตามจำนวนครั้งที่ตรวจแล้วก็ได้
เขียน test ให้ 1011 ไม่กลายเป็นการปฏิเสธ
กติกา "ระบบไม่ปฏิเสธเอง" พังได้ง่ายถ้าวันหนึ่งมีคนมาแก้ recordError() เราจึงเขียน test ไว้ตรง ๆ ใช้ Http::fake() แทน SlipOK จริง และ Http::preventStrayRequests() กันไม่ให้ request ไหนหลุดออกไปข้างนอก
use App\Enums\SlipCheckStatus;
use App\Enums\SlipDecision;
use App\Jobs\CheckSlip;
use App\Models\ShopAccount;
use App\Models\Slip;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Http::preventStrayRequests();
Storage::fake('slips');
});
function uploadedSlip(): Slip
{
return Slip::factory()->create([
'path' => UploadedFile::fake()->image('slip.jpg')->store('uploads', 'slips'),
]);
}
test('a slip SlipOK cannot find yet stays waiting instead of being rejected', function () {
Http::fake(['api.slipok.com/*' => Http::response([
'success' => false,
'code' => 1011,
'message' => 'QR Code หมดอายุ หรือ ไม่มีรายการอยู่จริง',
])]);
$slip = uploadedSlip();
CheckSlip::dispatchSync($slip);
expect($slip->fresh())
->check_status->toBe(SlipCheckStatus::Waiting)
->decision->toBeNull()
->error_code->toBe(1011);
});
test('a rejected slip with the same reference does not make a resend a duplicate', function () {
ShopAccount::factory()->create(['account_no' => '123-4-51234-6', 'is_active' => true]);
Slip::factory()->create(['trans_ref' => 'REF0001', 'decision' => SlipDecision::Rejected]);
Http::fake(['api.slipok.com/*' => Http::response([
'success' => true,
'data' => [
'transRef' => 'REF0001',
'amount' => 1000,
'transTimestamp' => '2026-10-01T04:52:46.000Z',
'receiver' => ['account' => ['value' => 'xxx-x-x1234-x']],
],
])]);
$slip = uploadedSlip();
CheckSlip::dispatchSync($slip);
expect($slip->fresh()->check_status)->toBe(SlipCheckStatus::Verified);
});
ควรเพิ่มอีกสองสามกรณีด้วย คือ 1007 ต้องได้ NeedsReview บัญชีผู้รับที่เลขไม่ตรงต้องได้ WrongReceiver และสลิปที่ยังรอพนักงานอยู่ต้องทำให้ใบใหม่ได้ Duplicate
SlipOK กับ EasySlip ราคาต่างกันพอสมควร
บริการตรวจสลิปในไทยมีหลายเจ้า สองเจ้าที่มีเอกสาร API เปิดให้อ่านคือ SlipOK กับ EasySlip ราคาจากหน้าเว็บของแต่ละเจ้า ณ วันที่ 11 ตุลาคม 2569
| SlipOK | EasySlip | |
|---|---|---|
| เริ่มใช้ฟรี | แพ็กเกจ OK BASIC 100 สลิปต่อเดือน เกินคิดสลิปละ 1 บาท | ทดลองใช้ 7 วัน |
| แพ็กเกจเล็กสุดที่จ่ายเงิน | 350 บาท/เดือน 500 สลิป | 99 บาท/เดือน 250 ครั้ง |
| ราว 1,000 สลิปต่อเดือน | 600 บาท | 350 บาท |
| สลิปที่ยังตรวจไม่ได้ | 1010 รอตามนาทีที่บอก, 1011 รวมกับ "ไม่มีรายการ" | SLIP_PENDING สำหรับสลิปธนาคารกรุงเทพที่เพิ่งโอน, SLIP_NOT_FOUND รวมกับสลิปที่ไม่ถูกต้อง |
ราคาของ SlipOK อยู่ที่หน้าแพ็กเกจของ SlipOK ถ้าจ่ายล่วงหน้าหลายเดือนจะได้ส่วนลด ส่วนรหัส error ของ EasySlip อยู่ใน Error Codes สังเกตว่า SLIP_NOT_FOUND ของ EasySlip ก็รวม "ไม่พบสลิป" กับ "สลิปไม่ถูกต้อง" ไว้ด้วยกันเหมือน 1011 ของ SlipOK แนวคิดในโพสต์นี้จึงใช้ได้กับทั้งสองเจ้า เปลี่ยนแค่ตารางว่ารหัสไหนรอได้ รหัสไหนต้องให้คนดู






