ทดสอบระบบรับชำระเงินใน Laravel โดยไม่ตัดเงินใครจริง

ระบบรับชำระเงินทดสอบได้สามชั้น คือ test ที่จำลองคำตอบของ gateway sandbox ของ gateway และเครื่องที่ใช้ข้อมูลชุดจริง ชั้นสุดท้ายอันตรายที่สุด จึงต้องมีสวิตช์ปิดการคุยกับ gateway ที่ปิดไว้ตั้งแต่แรก

เผยแพร่
เวลาอ่าน
5 นาที
ในโพสต์นี้ 7 หัวข้อ
  1. ระบบรับชำระเงินทดสอบได้สามชั้น
  2. เรื่องที่ test ในเครื่องควรคุมไว้
  3. จำลองคำตอบของ gateway ด้วย Http::fake()
  4. เซ็น callback ปลอมเองเพื่อทดสอบฝั่งรับผล
  5. sandbox ของ gateway ใช้ทดสอบทั้งเส้นทาง
  6. สวิตช์ปิดการคุยกับ gateway บนเครื่องที่ใช้ข้อมูลชุดจริง
  7. กรณีที่อาจเจอในงานจริง

ระบบรับเงินเป็นส่วนที่พังแล้วเสียหายเร็วที่สุด คำสั่งซื้อที่ลูกค้าจ่ายแล้วแต่ยังค้างรอชำระ แต้มที่ให้ซ้ำ หรือแย่กว่านั้นคือเครื่องทดสอบที่ไปตัดเงินลูกค้าจริง แต่ระบบนี้ก็ทดสอบด้วยการจ่ายเงินจริงทุกครั้งไม่ได้ โพสต์นี้แบ่งการทดสอบเป็นสามชั้น ว่าแต่ละชั้นจับอะไรได้ และต้องกันอะไรไว้

ตัวอย่างใช้ 2C2P ต่อจาก เชื่อม payment gateway กับ Laravel และ ตรวจผลชำระเงินจาก gateway ก่อนเชื่อ

ระบบรับชำระเงินทดสอบได้สามชั้น

ชั้น คุยกับ gateway จริงไหม จับอะไรได้
test ใน Laravel ไม่ จำลองคำตอบเอง logic ของระบบ รหัสตอบกลับแต่ละแบบ ลายเซ็น การรับผลซ้ำ
sandbox ของ gateway คุย แต่ไม่มีเงินจริง รูปแบบ request จริง หน้าชำระเงิน callback ที่ gateway ส่งมาจริง
เครื่องที่ใช้ข้อมูลชุดจริง ไม่ควรคุยเลย ถ้าไม่ได้ตั้งใจ หน้าจอและรายงานกับข้อมูลหน้าตาจริง

ชั้นแรกรันได้ทุกครั้งที่แก้โค้ด เร็ว และไม่ต้องต่อเน็ต ชั้นที่สองใช้ตอนเชื่อมครั้งแรกและก่อนขึ้น production ส่วนชั้นที่สามมีไว้ดูหน้าจอกับข้อมูลจริง ไม่ได้มีไว้ทดสอบการจ่ายเงิน แต่กลับเป็นที่ที่เงินจริงหลุดได้ง่ายที่สุด

เรื่องที่ test ในเครื่องควรคุมไว้

test ชั้นแรกไม่ต้องครอบทุกบรรทัด แต่ควรคุมเรื่องที่พังแล้วเสียเงินหรือเสียลูกค้า

  • คำตอบแต่ละแบบของ gateway สำเร็จ รอสแกน ล้มเหลว และลูกค้ายกเลิก แต่ละแบบต้องทำให้คำสั่งซื้อไปอยู่ในสถานะที่ถูก
  • error ที่เป็น JSON ธรรมดา ระบบต้องอ่านรหัส error ได้ ไม่ใช่โยน exception ตอน decode
  • ค่าที่ gateway ตรวจเข้ม คำอธิบายที่ส่งไปยาวไม่เกิน 250 ตัวอักษร และยอดเป็นทศนิยมสองตำแหน่ง
  • callback ที่เซ็นด้วย key อื่น ต้องไม่เปลี่ยนอะไรเลย
  • ผลเดียวกันที่มาสองครั้ง ต้องให้แต้มและส่งอีเมลครั้งเดียว

จำลองคำตอบของ gateway ด้วย Http::fake()

ฐานของชั้นแรกคือ test ที่ไม่มี request ไหนหลุดออกไปข้างนอก ซึ่งเราตั้งไว้ใน TestCase ตามที่อธิบายใน กัน test ใน Laravel ไม่ให้ส่งอีเมล ยิง API หรือเข้าคิวจริง จากนั้นแต่ละ test ค่อยบอกว่า gateway จะตอบอะไร

gateway ที่ตอบเป็น JWT ทำให้ test ต้องเซ็นคำตอบเองด้วย secret key ของ test ซึ่งตั้งไว้ใน config ของ test ไม่ใช่ key จริง helper ตัวเดียวก็พอให้แต่ละ test บอกได้ว่า gateway ตอบอะไร

function gatewayAnswers(array $data): void
{
    Http::fake(['*/paymentInquiry' => Http::response([
        'payload' => JWT::encode($data, config('services.2c2p.secret_key'), 'HS256'),
    ])]);
}

it('marks the order paid when the inquiry says success', function () {
    $order = Order::factory()->pendingPayment()->create(['order_no' => 'INV-0001', 'total_amount' => 1000]);

    gatewayAnswers(['invoiceNo' => 'INV-0001', 'amount' => 1000, 'respCode' => '0000']);
    app(InquirePayment::class)($order);

    expect($order->refresh()->status)->toBe(OrderStatus::PendingShipping);
});

secret key ของ test ควรยาวอย่างน้อย 32 ตัวอักษร เพราะ firebase/php-jwt เวอร์ชันใหม่ไม่ยอมเซ็น HS256 ด้วย key ที่สั้นกว่านั้น ส่วนค่าที่ gateway ตรวจเข้ม ใช้ Http::assertSent() เปิด JWT ที่ระบบส่งออกไป แล้วเช็กความยาวของคำอธิบายกับรูปแบบของยอด

เซ็น callback ปลอมเองเพื่อทดสอบฝั่งรับผล

callback ทดสอบแบบเดียวกัน เซ็นข้อมูลแล้ว POST เข้า route ที่ gateway เรียก route ตอบ OK เสมอ เพราะหน้าที่ของมันคือเก็บ log แล้วส่งงานต่อ การตัดสินเกิดใน job ซึ่ง test สั่งให้ทำงานทันทีด้วย dispatchSync()

it('ignores a callback signed with another key', function () {
    $order = Order::factory()->pendingPayment()->create(['order_no' => 'INV-0001']);
    $forged = JWT::encode(['invoiceNo' => 'INV-0001', 'respCode' => '0000'], 'not-the-merchant-secret-key-0000000', 'HS256');

    $this->postJson(route('payment.callback'), ['payload' => $forged])->assertOk();

    expect(fn () => ReadPaymentCallback::dispatchSync(PaymentLog::query()->latest('id')->first()))
        ->toThrow(SignatureInvalidException::class)
        ->and($order->refresh()->status)->toBe(OrderStatus::PendingPayment);
});

test ของผลที่มาซ้ำก็หน้าตาคล้ายกัน เซ็นด้วย key ที่ถูก ส่งผลเดียวกันสองรอบ แล้วนับว่าลูกค้าได้แต้มรายการเดียว ทั้งหมดนี้รันกับฐานข้อมูลที่โหลดจาก snapshot แล้วห่อแต่ละ test ด้วย transaction ตามแบบใน จัดฐานข้อมูลสำหรับ test ด้วย snapshot การล็อกแถวด้วย lockForUpdate() ก็ทำงานได้ปกติใน transaction ของ test

sandbox ของ gateway ใช้ทดสอบทั้งเส้นทาง

test ในเครื่องบอกไม่ได้ว่า request ที่ระบบสร้างตรงกับที่ gateway รับจริงไหม และไม่เห็นหน้าชำระเงินจริง ส่วนนี้ต้องใช้ sandbox ซึ่ง 2C2P ให้ merchant ID กับ secret key ของ sandbox แยกจาก production และมีบัตรทดสอบ พร้อม OTP ให้ลองจ่ายด้วยบัตรได้โดยไม่มีเงินจริง

สิ่งที่ควรลองใน sandbox

  • จ่ายสำเร็จ ล้มเหลว และยกเลิกกลางทาง ทั้งบัตรและ QR แล้วดูว่าสถานะในระบบตรงกับในหน้าจัดการของ gateway
  • callback มาถึงจริง gateway ต้องเรียก URL ของเราได้ เครื่อง dev ที่อยู่หลัง NAT รับ callback ไม่ได้ ระหว่างนั้นยังทดสอบผ่านทางเบราว์เซอร์กลับมากับ inquiry ได้ หรือเปิด tunnel ชั่วคราวให้ URL ของเครื่องเราเข้าถึงได้จากข้างนอก
  • หมดอายุ ปล่อย QR ไว้จนหมดเวลา แล้วดูว่าหน้าเว็บหยุดถาม และคำสั่งซื้อไม่ค้าง

ก่อนได้ key จริง 2C2P ยังให้ร้านผ่านขั้นตอน sign-off บน sandbox ด้วย ตอนที่เราทำ เอกสารของ gateway ให้ทดสอบครบสี่สถานะของรายการ คือจ่ายสำเร็จ ยกเลิกในวันเดียวกัน (void) ตัดรอบแล้ว (settled) และคืนเงิน (refund) แล้วยืนยันว่าทั้งฝั่งเบราว์เซอร์และ callback ได้ผลถูก ควรขอรายการนี้จาก gateway ตั้งแต่เริ่มโปรเจกต์ เพราะ refund ทำได้หลังรายการตัดรอบแล้วเท่านั้น ต้องรอข้ามวันอย่างน้อยหนึ่งรอบ

สวิตช์ปิดการคุยกับ gateway บนเครื่องที่ใช้ข้อมูลชุดจริง

ชั้นที่อันตรายที่สุดคือเครื่อง dev หรือ staging ที่นำฐานข้อมูลจาก production มาลง เพื่อดูหน้าจอกับข้อมูลหน้าตาจริง ตามแบบใน ดึงฐานข้อมูล production ลงเครื่อง ข้อมูลชุดนั้นมีคำสั่งซื้อจริง อีเมลจริง และ token การจ่ายเงินจริง ถ้า .env ของเครื่องนั้นมี key จริงติดมาด้วย ปุ่มเช็กสถานะหรือ job ที่วิ่งตามรอบก็คุยกับ gateway จริงได้ทันที

ระบบหลังร้านที่เราทำแก้เรื่องนี้ด้วยสวิตช์เปิดปิดแยกตามช่องทาง ทุกช่องทางที่ส่งอะไรออกไปข้างนอก ทั้ง SMS อีเมล และ gateway มีสวิตช์ของตัวเอง และมีสามเรื่องที่ทำให้สวิตช์นี้ได้ผล

  • ปิดไว้ตั้งแต่ข้อมูลเข้าเครื่อง คำสั่งที่รันต่อจากการนำข้อมูลเข้าทุกครั้งจะปิดทุกสวิตช์ แล้วเปิดเฉพาะช่องทางที่เครื่องนั้นควรเปิด ตามรายการต่อ environment ที่เขียนไว้ในโค้ด บน production สวิตช์จะเปิดก็ต่อเมื่อสั่งด้วย flag ที่ตั้งใจใส่เท่านั้น
  • ตรวจที่จุดเดียวที่ request ออกไป ตัวที่คุยกับ gateway เช็กสวิตช์ก่อนส่งทุกครั้ง ไม่ว่าจะเรียกมาจากหน้าเว็บ ปุ่มในหลังร้าน หรือ job
  • ตอบเป็นรหัสพิเศษ แทนการตอบว่าล้มเหลว ตอนปิดอยู่ ระบบได้รหัส BLOCKED และ inquiry ที่ได้รหัสนี้ปล่อยคำสั่งซื้อไว้ตามเดิม ถ้าตอบเป็นล้มเหลว ระบบจะบันทึกรหัสที่ไม่ได้มาจาก gateway ทับข้อมูลการจ่ายเงินเดิมของคำสั่งซื้อ

ฝั่ง test ทำกลับกัน TestCase เปิดทุกสวิตช์ไว้ให้ test ปกติทำงานได้ ส่วน test ที่ตรวจสวิตช์ค่อยปิดเอง แล้วเช็กว่าไม่มี request ออกไปและคำสั่งซื้อไม่เปลี่ยน

ด่านสุดท้ายอยู่ใน config/services.php URL ของ gateway ทุกตัวมีค่าตั้งต้นเป็น sandbox ส่วน production ต้องตั้ง URL จริงใน .env เอง เครื่องที่ลืมตั้งค่าจึงคุยกับ sandbox แทน และ key ของ sandbox กับ production ไม่ควรอยู่ใน .env ไฟล์เดียวกันเลย

กรณีที่อาจเจอในงานจริง

  • เครื่อง staging ส่งอีเมลยืนยันการชำระเงินให้ลูกค้าจริง เกิดได้แม้ gateway ปิดอยู่ ถ้าแอดมินกดเปลี่ยนสถานะเองในหลังร้าน สวิตช์จึงต้องครอบทุกช่องทางที่ถึงลูกค้า ไม่ใช่แค่ gateway
  • test ผ่านหมด แต่ sandbox ตอบ error ส่วนใหญ่เป็นค่าที่ test ไม่ได้จำลอง เช่นช่องที่บังคับใน API หนึ่งแต่ไม่บังคับในอีก API พอเจอแล้วให้เพิ่มเป็น test ตัวใหม่ทันที
  • sandbox ช้าหรือปิดปรับปรุง test ชั้นแรกไม่ได้รับผลกระทบ เพราะไม่ได้คุยกับ sandbox เลย ส่วนการทดสอบทั้งเส้นทางรอได้
  • ต้องลองจ่ายจริงหลังขึ้น production ใช้บัตรหรือบัญชีของทีม จ่ายยอดเล็ก ๆ หนึ่งรายการ แล้วยกเลิกหรือคืนเงินตามขั้นตอนของ gateway ดูให้ครบว่า callback มาถึง และอีเมลไปถึงอีเมลของทีม

จบซีรีส์ รับเงินในเว็บขายของออนไลน์ แล้ว

ดูทั้ง 6 ตอน →

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →