tva
← บทความเชิงลึก

เชื่อมต่อ Stripe Checkout สำหรับอีคอมเมิร์ซระหว่างประเทศ: กรณีเฉพาะและบทเรียน

เอกสารของ Stripe ดีมาก และการเชื่อมต่อเบื้องต้นมักทำงานได้ตั้งแต่ครั้งแรก แต่การขายอีคอมเมิร์ซระหว่างประเทศมีปัญหาที่คู่มือเริ่มต้นไม่ครอบคลุม ทั้งความน่าเชื่อถือของ webhook เมื่อรีสตาร์ทโครงสร้างพื้นฐาน รายละเอียดการคืนเงินในสกุลที่ต่างจากสกุลชำระยอดเงิน และภาระในการจัดการคีย์ API หลายสภาพแวดล้อม บทความนี้บันทึกกรณีเฉพาะที่เราพบและสิ่งที่เปลี่ยนตามมา

แบบจำลอง Checkout Session และข้อสมมติ

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

โดยค่าเริ่มต้น การตอบกลับของ Checkout Session มีข้อมูลการชำระเงินเพียงเล็กน้อย ฟิลด์ payment_intent เป็น ID แบบสตริง ไม่ใช่ออบเจ็กต์ที่ขยายข้อมูลแล้ว เรื่องนี้สำคัญเมื่อตรรกะจัดการและจัดส่งสินค้าต้องอ่าน payment_intent.latest_charge.payment_method_details เช่น บันทึกประเทศของบัตรเพื่อพิจารณา VAT (ใน API เวอร์ชันก่อน 2022-11-15 เส้นทางคือ payment_intent.charges.data[0] โดย Stripe ลบพร็อพเพอร์ตี charges ออกจาก PaymentIntents ในเวอร์ชันนั้น) เหตุการณ์ webhook ไม่เคยมีออบเจ็กต์ที่ขยายข้อมูลแล้ว ดังนั้นการรับข้อมูลนี้ในขั้นจัดการคำสั่งซื้อและจัดส่งสินค้าต้องเรียก API เพิ่มอีกครั้งต่อเหตุการณ์

พารามิเตอร์ expand รับอาร์เรย์ของเส้นทางที่คั่นด้วยจุด พารามิเตอร์นี้มีผลเฉพาะการตอบกลับของคำขอ API ที่ระบุให้ขยายข้อมูล การตั้งค่าในคำขอสร้างเซสชันไม่เปลี่ยนออบเจ็กต์เซสชันที่ Stripe ส่งภายหลังผ่าน webhook เพราะ Stripe ส่งออบเจ็กต์ในเหตุการณ์ในรูปแบบข้อมูลขั้นต่ำเสมอ ในตัวรับ webhook ให้ดึงเซสชันโดยขยาย ["payment_intent.latest_charge"] แทน ข้อแลกเปลี่ยนคือต้องเรียก API เพิ่มอีกครั้งต่อเหตุการณ์และการตอบกลับมีขนาดใหญ่ขึ้น ซึ่งสำคัญหากเก็บการตอบกลับดิบในระบบบันทึก

ความน่าเชื่อถือของ webhook

Stripe ส่ง webhook แบบลองใหม่ หากปลายทางไม่ตอบสถานะ 2xx หรือหมดเวลารอ Stripe จะลองส่งอีกโดยเพิ่มช่วงรอแบบเอ็กซ์โพเนนเชียลนานสูงสุด 3 วันในโหมดใช้งานจริง ส่วนเหตุการณ์ใน sandbox จะลองส่งใหม่ 3 ครั้งภายในช่วงไม่กี่ชั่วโมง การออกแบบนี้ดี แต่สร้างปัญหาความถูกต้องหากตัวรับทำซ้ำแล้วเกิดผลซ้ำซ้อน การสร้างคำสั่งซื้อ จองสินค้า และส่งอีเมลล้วนเป็นผลข้างเคียงที่ควรเกิดเพียงครั้งเดียว

วิธีมาตรฐานคือบันทึก ID ของเหตุการณ์ที่ประมวลผลแล้วก่อนดำเนินการที่มีผลข้างเคียง เราใช้ตารางฐานข้อมูลเรียบง่ายโดยให้ ID เหตุการณ์ของ Stripe เป็นคีย์หลัก ตัวรับอ่านฟิลด์ id ที่ส่งมา ลองเพิ่มข้อมูล และดำเนินงานต่อเมื่อเพิ่มสำเร็จเท่านั้น ID ซ้ำทำให้เกิดข้อขัดแย้งและตัวรับตอบ 200 ทันที เพื่อให้วิธีนี้ปลอดภัย ต้องยืนยันการเพิ่มข้อมูลในธุรกรรมฐานข้อมูลเดียวกับผลข้างเคียง (หรือทำเครื่องหมายแถวว่าประมวลผลแล้วหลังผลข้างเคียงสำเร็จเท่านั้น) มิฉะนั้น หากระบบล่มหลังเพิ่มข้อมูล การลองส่งใหม่ของ Stripe จะถูกมองว่าเป็นเหตุการณ์ซ้ำและคำสั่งซื้อจะไม่ได้รับการจัดการและจัดส่งเลย

เรื่องที่เอกสารกล่าวถึงไม่มากพอคือช่วงห่างระหว่างการรับเหตุการณ์ checkout.session.completed กับการเรียกเก็บเงินจริงตามลำดับเวลาชำระยอดของ Stripe สำหรับบางวิธี โดยเฉพาะการโอนเงินผ่านธนาคารและบริการซื้อก่อนจ่ายทีหลังบางประเภทในเยอรมนีกับเนเธอร์แลนด์ เซสชันจบก่อนยืนยันเงิน ในกรณีเหล่านี้ เซสชันจบโดยมี payment_status เป็น unpaid และเหตุการณ์ที่ถูกต้องคือ checkout.session.async_payment_succeeded เพื่อเริ่มจัดการคำสั่งซื้อและจัดส่งสินค้า และ checkout.session.async_payment_failed เพื่อจัดการเมื่อชำระเงินล้มเหลว การจัดการและจัดส่งทุกครั้งที่ได้รับ checkout.session.completed โดยไม่ตรวจ payment_status จะทำให้ส่งคำสั่งซื้อที่ภายหลังชำระเงินไม่สำเร็จ

เราพบปัญหาในรอบรีสตาร์ทคอนเทนเนอร์ด้วย ระหว่างนำระบบขึ้นใช้งาน คำขอ webhook ที่กำลังส่งอาจมาถึงตอนคอนเทนเนอร์เก่าปิดและตัวใหม่เริ่ม Nginx รักษาการเชื่อมต่อระหว่างเปลี่ยน upstream แต่เราพบเหตุการณ์หลุดในช่วงสั้น ๆ วิธีแก้คือเพิ่ม keepalive_timeout และ proxy_read_timeout อย่างชัดเจนในค่ากำหนด Nginx และอาศัยกลไกลองส่งใหม่ของ Stripe แทนการพยายามทำให้ช่วงนำระบบขึ้นใช้งานไม่มีการหยุดให้บริการที่ระดับตัวรับ webhook

การจัดการคีย์โหมดจริงและโหมดทดสอบ

ข้อผิดพลาดที่พบบ่อยที่สุดในการเชื่อมต่อ Stripe คือไม่แยกคีย์โหมดจริงกับโหมดทดสอบอย่างแท้จริง กรณีที่เห็นชัดคือใช้คีย์จริงในสภาพแวดล้อมทดสอบโดยไม่ตั้งใจจนเรียกเก็บเงินจริง กรณีที่เห็นยากกว่าคือใช้คีย์ทดสอบในสภาพแวดล้อมทดสอบก่อนใช้งานจริงที่ใช้ฐานข้อมูลร่วมกับข้อมูลลักษณะเดียวกับระบบใช้งานจริง

เรากำหนดสามสภาพแวดล้อม แต่ละแห่งมีข้อมูลรับรอง Stripe ครบชุดของตนเอง ได้แก่ development, staging และ production development ใช้โหมดทดสอบ staging ก็ใช้โหมดทดสอบ แต่ใช้คีย์จำกัดสิทธิ์คนละชุดและปลายทาง webhook แยกที่ลงทะเบียนในแดชบอร์ด Stripe ส่วน production ใช้โหมดจริง คีย์ทั้งหมดส่งเข้าโปรแกรมขณะทำงานผ่านตัวแปรสภาพแวดล้อม ไม่เก็บในระบบควบคุมเวอร์ชัน และไม่ใส่ในอิมเมจ Docker ตอนสร้าง

รหัสลับลงนาม webhook เป็นข้อมูลรับรองคนละตัวกับคีย์ API แต่ละปลายทางที่ลงทะเบียนในแดชบอร์ด Stripe มีรหัสลับของตัวเอง สามสภาพแวดล้อมจึงมีสามรหัสลับ คัดลอกผิดได้ง่ายโดยเฉพาะเมื่อตั้งค่าการนำระบบขึ้นใช้งานใหม่ เราบันทึกความสัมพันธ์อย่างชัดเจนในคู่มือปฏิบัติงานภายในว่าใช้บัญชี Stripe ใด ปลายทางใด รหัสลับใด และคำนำหน้าคีย์ใด แม้ดูเป็นภาระ แต่ทางเลือกคือเสียเวลาวินิจฉัยเหตุการณ์โหมดจริงที่ตรวจลายเซ็นไม่ผ่านโดยไม่มีสัญญาณชัดเจน

ฟีเจอร์คีย์จำกัดสิทธิ์ของ Stripe ยังถูกใช้ไม่มาก แทนที่จะให้รหัสลับเต็มสิทธิ์แก่ทุกบริการ คุณสร้างคีย์ที่อนุญาตเฉพาะงานที่บริการต้องทำได้ ตัวประมวลผลงานจัดการและจัดส่งสินค้าที่อ่าน payment intents และสร้างการคืนเงินเท่านั้น ไม่ต้องมีสิทธิ์เขียนข้อมูลสินค้าหรือการสมัครสมาชิก เราออกคีย์จำกัดสิทธิ์แยกตามบริการและหมุนเวียนแยกจากรหัสลับหลัก

การจัดการสกุลเงินและปัญหาการคืนเงิน

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

การคืนเงินใช้สกุลที่ลูกค้าจ่าย แต่ยอดหักจากยอดคงเหลือ Stripe คำนวณด้วยอัตราแลกเปลี่ยนขณะคืนเงิน ไม่ใช่ขณะเรียกเก็บเดิม หากอัตราเปลี่ยนไปในทางเสียเปรียบ ยอดคืนในสกุลชำระยอดเงินอาจมากกว่าที่ได้รับ นี่ไม่ใช่ข้อผิดพลาด แต่เป็นพฤติกรรมที่คาดไว้ เรื่องนี้มีระบุในคู่มือการคืนเงินและข้อโต้แย้งของ Stripe แต่พลาดได้ง่าย และมีผลต่อต้นทุนจริงเมื่อขายหลายสกุลเงินด้วยอัตรากำไรต่ำ (ณ เดือนตุลาคม 2026 Adaptive Pricing ของ Stripe เป็นข้อยกเว้น โดยการคืนเงินใช้อัตราแลกเปลี่ยนของธุรกรรมเดิม)

วิธีลดความเสี่ยงคือจำกัดสกุลเงินที่ยอมรับให้ตรงกับความเสี่ยงที่เข้าใจ หรือสำรองเงินเผื่อความผันผวนของอัตราแลกเปลี่ยน สำหรับธุรกิจปริมาณสูง บัญชีชำระยอดหลายสกุลของ Stripe ช่วยถือยอดคงเหลือในสกุลที่กำหนดและชำระยอดแยกแต่ละสกุล จึงไม่มีการแปลงตอนคืนเงินในสกุลเหล่านั้น ข้อแลกเปลี่ยนคือการบริหารเงินที่ซับซ้อนขึ้น

เรายังพบกรณีเฉพาะในการคืนเงินบางส่วนของคำสั่งซื้อที่เรียกเก็บเป็นสกุลเงินต่างจากสกุลหลักภายในของเรา (Checkout Session หนึ่งเซสชันใช้สกุลเงินเดียว ดังนั้นทุกรายการในคำสั่งซื้อจึงใช้สกุลเงินเดียวกัน) การคืนเงินบางส่วนต้องระบุยอดในสกุลเงินที่เรียกเก็บเดิม หากระบบจัดการคำสั่งซื้อเก็บยอดเป็นสกุลหลักภายใน ต้องเก็บยอดและสกุลที่ลูกค้าจ่ายเดิมควบคู่กับข้อมูลคำสั่งซื้อ ไม่ใช่เก็บเพียงยอดแปลง เราเพิ่มสองฟิลด์นี้ในตารางคำสั่งซื้อหลังการคืนบางส่วนครั้งแรกไม่ผ่านด้วยข้อผิดพลาดการตรวจสอบที่ชวนสับสน

วิธีชำระเงินที่ใช้ได้ตามประเทศ

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

เรื่องนี้สำคัญต่อชุดทดสอบ หากทดสอบว่าหน้าชำระเงินแสดงวิธีเฉพาะ ผลจะต่างกันตามภูมิภาค เราเขียนการทดสอบการเชื่อมต่อโดยใช้พารามิเตอร์ payment_method_types อย่างชัดเจนแทนวิธีชำระเงินแบบไดนามิก เพื่อให้ได้ชุดที่แน่นอน Checkout ในระบบใช้งานจริงใช้วิธีชำระเงินแบบไดนามิก ความต่างนี้ตั้งใจและมีเอกสารกำกับ

SEPA Direct Debit, iDEAL, Bancontact และ Przelewy24 ต่างมีข้อกำหนดที่การจ่ายด้วยบัตรไม่มี Checkout แบบโฮสต์เก็บรายละเอียดเฉพาะของแต่ละวิธีให้เอง เช่น IBAN และการยอมรับคำสั่งหักบัญชีสำหรับ SEPA แต่ยังมีข้อจำกัดอื่น โดย SEPA Direct Debit ใน Checkout กำหนดให้ทุกรายการมีราคาเป็น EUR และเป็นวิธีที่แจ้งผลภายหลัง จึงต้องใช้เหตุการณ์แบบอะซิงโครนัสที่กล่าวข้างต้น เราทดสอบทุกวิธีที่เปิดใช้ตลอดขั้นตอนชำระเงิน ไม่ใช่แค่ขั้นยืนยันการชำระเงิน

สิ่งที่เปลี่ยนหลังพบปัญหาแรกในระบบใช้งานจริง

หลังใช้งานกับธุรกรรมจริงในช่วงไม่กี่เดือนแรก เราปรับโครงสร้างสามอย่าง อย่างแรก ย้ายการเก็บคีย์ป้องกันผลซ้ำจากหน่วยความจำไปฐานข้อมูล เพราะข้อมูลในหน่วยความจำไม่คงอยู่หลังรีสตาร์ทคอนเทนเนอร์ อย่างที่สอง บันทึก ID เหตุการณ์ Stripe ประเภทเหตุการณ์ และผลของการเรียกตัวรับ webhook ทุกครั้ง ไม่ใช่เฉพาะข้อผิดพลาด จึงสร้างลำดับเหตุการณ์ย้อนกลับได้เมื่อวินิจฉัยสถานะคำสั่งซื้อที่ไม่ตรงกัน อย่างที่สาม เลิกใช้บันทึกเหตุการณ์ใน Stripe Dashboard เป็นเครื่องมือวินิจฉัยหลักและใช้บันทึกของเราเป็นแหล่งอ้างอิง

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


บทความที่เกี่ยวข้อง

บทความที่เกี่ยวข้อง