Skip to main content

QRCallback Lambda Function

Quick Reference

  • Function Name: payment3-qrcallback-${BRANCH}
  • Handler: qrcallback.lambda_handler
  • Runtime: Python 3.12 (Docker Image)
  • Timeout: 120 seconds
  • Memory: 1024 MB
  • Trigger: API Gateway (POST /qrcallback)
  • Template.yaml: Lines 156-200

Function Overview

The QRCallback function processes QR payment callbacks from KBank. It validates payment amounts against KBank records, saves payment records, and invokes ProcessPaidOrderInternal for successful payments. This function handles Thai QR code payment callbacks.

Entry Point

File: functions/callback/qrcallback.py
Handler: lambda_handler(event, _)

def lambda_handler(event, _):
"""
Record QR payment callback.
"""
processor = None
order_id = None
charge_id = None

try:
body = dict(urllib.parse.parse_qsl(event["body"]))
order_id = body["orderId"]
status_string = body["status"]
amount = float(body["amount"])
charge_id = body["chargeId"]
processor = Processor(order_id, status_string, amount, charge_id)
except Exception as e:
charge_id = errorString()
order_id = body.get("orderId") if isinstance(body, dict) else None
status_string = body.get("status") if isinstance(body, dict) else str(body)
amount = body.get("amount") if isinstance(body, dict) else 0
processor = Processor(order_id or "unknown", status_string, amount, charge_id)
log_callback_error(
error=e,
function_name="qrcallback",
event=event,
orderId=order_id,
chargeId=charge_id,
response_data=None
)
send_callback_error_email(
error=e,
function_name="qrcallback",
orderId=order_id,
chargeId=charge_id,
event=event
)
return processor.error_html_response(str(e) + errorString())

try:
qr_processor = processor.qr_processor
success, paid_amount = qr_processor.check_kbank_record
if not success:
error_msg = f"Kbank record not found for charge id {charge_id}"
error_exception = Exception(error_msg)
log_callback_error(
error=error_exception,
function_name="qrcallback",
event=event,
orderId=order_id,
chargeId=charge_id,
response_data=None
)
send_callback_error_email(
error=error_exception,
function_name="qrcallback",
orderId=order_id,
chargeId=charge_id,
event=event
)
return processor.error_html_response(error_msg)
if abs(float(paid_amount) - float(processor.amount)) > 2.0: # Allow differences up to 2 for floating point errors
error_msg = f"Kbank record amount {paid_amount} does not match order amount {processor.amount}"
error_exception = Exception(error_msg)
log_callback_error(
error=error_exception,
function_name="qrcallback",
event=event,
orderId=order_id,
chargeId=charge_id,
response_data=None
)
send_callback_error_email(
error=error_exception,
function_name="qrcallback",
orderId=order_id,
chargeId=charge_id,
event=event
)
return processor.error_html_response(error_msg)
qr_processor.save_record()
qr_processor.process_paid_order()
except Exception as e:
log_callback_error(
error=e,
function_name="qrcallback",
event=event,
orderId=order_id,
chargeId=charge_id,
response_data=None
)
send_callback_error_email(
error=e,
function_name="qrcallback",
orderId=order_id,
chargeId=charge_id,
event=event
)
return processor.error_html_response(str(e) + errorString())
return processor.response

Event Structure

API Gateway Event

Path: POST /qrcallback
Auth: NONE (public endpoint - called by KBank)

Request Body (URL-encoded form data):

orderId=002500048744&status=success&amount=6&chargeId=chrg_prod_11430784e550fb6ea4f69902e0deda2591aa6

Required Fields:

  • orderId - Villa order ID (required)
  • status - Payment status: "success" or "failure" (required)
  • amount - Paid amount (required)
  • chargeId - KBank charge ID (required)

Response (Success - HTML redirect):

<html>
<head>
<meta http-equiv="Refresh" content="0; URL='https://shop.villamarket.com/thankyou?orderId=...&status=success&amount=...&chargeId=...'">
</head>
<body>
<h2>Payment Processing Complete</h2>
<p>Click below to return to the store:</p>
<a href='...'>Click here to return to Villa Market</a>
</body>
</html>

DynamoDB Tables

QRCallbackRecordTable

Table Name: payment3-qr-callback-record-&#123;BRANCH&#125;
Region: ap-southeast-1

Schema (from template.yaml lines 1162-1187):

  • Hash Key: orderId (String)
  • Range Key: created_at (String)
  • GSI: chargeId-index (chargeId hash, created_at range)

Note: This table is referenced in template.yaml but the function uses QRPaymentRecordTable for actual payment records.

QRPaymentRecordTable

Table Name: payment3-qr-payment-record-&#123;BRANCH&#125;
Region: ap-southeast-1

PynamoDB Model:

class QRPaymentRecordTable(Model):
class Meta:
table_name = f"payment3-qr-payment-record-{BRANCH}"
region = "ap-southeast-1"

tokenId = UnicodeAttribute(hash_key=True)
orderId = UnicodeAttribute()
createdAt = UTCDateTimeAttribute()
chargeId = UnicodeAttribute(null=True)
status = BooleanAttribute(null=True)
paidAmount = NumberAttribute(null=True)


def __post_init__(self):
self.createdAt = datetime.now()

Key Fields:

  • Hash Key: tokenId (UnicodeAttribute) - Uses chargeId as tokenId
  • orderId - Villa order ID
  • chargeId - KBank charge ID
  • status - Payment status (True = success, False = failure)
  • paidAmount - Amount paid (NumberAttribute)

Save Pattern:

from src.qrPaymentRecordTable import QRPaymentRecordTable

QRPaymentRecordTable(
tokenId=charge_id,
orderId=order_id,
chargeId=charge_id,
status=kbank_success,
createdAt=datetime.now(),
paidAmount=paid_amount
).save()

OrderTable

Table Name: order-table-dev
Region: ap-southeast-1

Query Pattern:

from src.order import OrderTable

order = OrderTable.get(order_id)
order_data = order.data
grand_total = order_data["grandTotal"]

External Services

KBank QR Payment API

Endpoint: &#123;BASE_URL&#125;/qr/v2/qr/&#123;charge_id&#125;
Method: GET
Authentication: x-api-key header with KBank secret key

Request:

headers = {
"content-type": "application/json",
"x-api-key": os.environ["KBANK_SECRET_KEY"]
}
response = requests.get(
f"{os.environ['BASE_URL']}/qr/v2/qr/{charge_id}",
headers=headers
)

Response:

{
"status": "success",
"amount": 6.0,
...
}

Validation:

  • Check response["status"] == "success"
  • Extract response["amount"]
  • Compare with order grandTotal (tolerance: 2.0 THB)

AWS Secrets Manager

Secrets:

  • kbank-dev (for dev branch)
  • kbank-prod (for master branch)

Secret Structure:

{
"b2c": "api_key:secret_key",
"url": "https://api-endpoint.kasikornbank.com",
"mid": "merchant_id"
}

Lambda Invocations

ProcessPaidOrderInternal

Function: payment3-process-paid-order-internal-$&#123;BRANCH&#125;
Invoked When: Payment is successful (kbank_success is True)

Payload:

{
"orderId": order_id,
"paymentType": "qr",
"success": kbank_success,
"chargeId": charge_id
}

Invocation Type: Event (asynchronous)

Code:

def process_paid_order(self):
print("payment is successful, invoking ProcessPaidOrder Lambda")
lambda_client = Lambda()
response = lambda_client.invoke(
functionName=f"payment3-process-paid-order-internal-{BRANCH}",
input=json.dumps(
{
"orderId": self.order_id,
"paymentType": "qr",
"success": self.kbank_success,
"chargeId": self.charge_id,
}
),
invocationType="Event", # Asynchronous invocation
)
print("process paid order invoked")
print(response)
return response

Processing Flow

Main Flow

  1. Parse Event: Extract URL-encoded form data
  2. Create Processor: Initialize Processor with orderId, status, amount, chargeId
  3. Create QR Processor: Initialize QRCallbackProcessor
  4. Check KBank Record: Query KBank API to verify payment
  5. Validate Amount: Compare KBank paid amount with order grandTotal
  6. Save Record: Save to QRPaymentRecordTable
  7. Process Paid Order: Invoke ProcessPaidOrderInternal if successful
  8. Return Response: Return HTML redirect response

Detailed Algorithm

1. Parse event["body"] as URL-encoded form data
2. Extract: orderId, status, amount, chargeId
3. Create Processor(orderId, status_string, amount, chargeId)
4. Create QRCallbackProcessor(charge_id, order_id, success)
5. Call check_kbank_record:
- GET {BASE_URL}/qr/v2/qr/{charge_id}
- Extract status and amount from response
- Return (success: bool, paid_amount: float)
6. Validate KBank record exists:
- If not success: Log error, send email, return error HTML
7. Validate amount match:
- If abs(paid_amount - order_amount) > 2.0:
- Log error, send email, return error HTML
8. Save QRPaymentRecordTable:
- tokenId = charge_id
- orderId = order_id
- chargeId = charge_id
- status = kbank_success
- paidAmount = paid_amount
9. If kbank_success is True:
- Invoke ProcessPaidOrderInternal with paymentType="qr"
10. Return HTML redirect response

Error Handling

Parse Errors

Exception: Caught in outer try-except

Handling:

  • Create Processor with fallback values
  • Log error to CallbackErrorLogTable
  • Send error email
  • Return error HTML response

KBank Record Not Found

Error: check_kbank_record returns success=False

Handling:

  • Log error with message "Kbank record not found for charge id {charge_id}"
  • Send error email
  • Return error HTML response

Amount Mismatch

Error: abs(paid_amount - order_amount) > 2.0

Handling:

  • Log error with amount mismatch details
  • Send error email
  • Return error HTML response
  • Tolerance: 2.0 THB (for floating-point precision)

Error HTML Response

Function: processor.error_html_response(error)

def error_html_response(self, error: str):
return f"""
<html>
<body>
<h1>Callback recorded in error {'successfully' if self.success else 'failed'}</h1>
<p>error is {error}</p>
<p>order id is {self.order_id}</p>
<p>status is {self.status_string}</p>
<p>amount is {self.amount}</p>
<p>charge id is {self.charge_id}</p>
</body>
</html>
"""

IAM Policies

From template.yaml (lines 167-187):

  • DynamoDBWritePolicy for QRCallbackRecordTable (line 168-169)
  • DynamoDBCrudPolicy for QRPaymentRecordTable (line 170-171)
  • DynamoDBReadPolicy for order-table-dev (line 172-173)
  • DynamoDBCrudPolicy for payment3-card-payment-record-master (line 174-175)
  • LambdaInvokePolicy for ProcessPaidOrderInternal (line 176-177)
  • AWSSecretsManagerGetSecretValuePolicy for kbank-dev (line 178-179)
  • AWSSecretsManagerGetSecretValuePolicy for kbank-prod (line 180-181)
  • DynamoDBWritePolicy for CallbackErrorLogTable (line 182-184)
  • SESCrudPolicy for sending error emails (line 185-187)

Dependencies

External Lambda Functions

  • payment3-process-paid-order-internal-$&#123;BRANCH&#125; - Process paid QR orders (invoked)

DynamoDB Tables

  • payment3-qr-callback-record-$&#123;BRANCH&#125; - Callback records (write)
  • payment3-qr-payment-record-$&#123;BRANCH&#125; - Payment records (write)
  • order-table-dev - Order data (read)
  • payment3-card-payment-record-master - Master payment records (read)
  • payment3-callback-error-log-$&#123;BRANCH&#125; - Error logs (write)

External Services

  • KBank QR API: Verify payment status and amount
  • AWS Secrets Manager: Get KBank API credentials
  • SES: Send error notification emails

Environment Variables

  • BRANCH - Git branch name
  • KBANK_API_KEY - Set from secrets
  • KBANK_SECRET_KEY - Set from secrets (used as x-api-key)
  • BASE_URL - KBank API endpoint (from secrets)
  • MID - Merchant ID (from secrets)

Python Dependencies

  • requests - HTTP client for KBank API
  • lambdasdk - Lambda invocation utilities
  • nicHelper - AWS utilities
  • sentry-sdk - Error tracking
  • pynamodb - DynamoDB ORM

Testing

Local Testing

File: functions/callback/qrcallback.py (lines 188-205)

if __name__ == "__main__":
import os
os.environ["BRANCH"] = "dev"

# Test event
test_event = {
"body": "orderId=002500048744&status=success&amount=6&chargeId=chrg_prod_11430784e550fb6ea4f69902e0deda2591aa6"
}

print("Testing QR callback handler...")
try:
r = lambda_handler(test_event, None)
print("✅ Test successful!")
print(f"Response status: {r.get('statusCode')}")
except Exception as e:
print(f"❌ Test failed: {e}")

Test Event Example

{
"body": "orderId=002500048744&status=success&amount=6&chargeId=chrg_prod_11430784e550fb6ea4f69902e0deda2591aa6"
}

SAM Testing

sam local invoke QRCallback -e test_event.json

Code Structure

File Organization:

functions/callback/
├── qrcallback.py # Main handler
└── src/
├── qrprocessor.py # QR callback processor
├── qrPaymentRecordTable.py # QR payment record table
└── order.py # OrderTable model

Key Classes:

  • Processor - Main callback processor
  • QRCallbackProcessor - QR-specific processing logic
  • KBankCreateOrder - Creates QR payment orders
  • QRNotify - Receives QR payment notifications
  • ProcessPaidOrderInternal - Processes paid QR orders

Common Patterns

KBank Record Validation

@cached_property
def check_kbank_record(self) -> tuple[bool, float]:
params = {
"charge_id": self.charge_id,
}
headers = {
"content-type": "application/json",
"x-api-key": os.environ["KBANK_SECRET_KEY"],
}
r = requests.get(
f"{os.environ['BASE_URL']}/qr/v2/qr/{params['charge_id']}",
headers=headers,
)
response = r.json()

# Print the full API response for debugging
print(f"KBank API Response for charge_id {self.charge_id}:")
print(f"Status Code: {r.status_code}")
print(f"Response: {response}")

try:
success = response["status"] == "success"
paid_amount = response["amount"]
return success, paid_amount
except KeyError as e:
print(f"KeyError in KBank API response: {e}")
print(f"Available keys in response: {list(response.keys())}")
raise

Amount Validation

if abs(float(paid_amount) - float(processor.amount)) > 2.0:  # Allow differences up to 2 for floating point errors
error_msg = f"Kbank record amount {paid_amount} does not match order amount {processor.amount}"
error_exception = Exception(error_msg)
log_callback_error(
error=error_exception,
function_name="qrcallback",
event=event,
orderId=order_id,
chargeId=charge_id,
response_data=None
)
send_callback_error_email(
error=error_exception,
function_name="qrcallback",
orderId=order_id,
chargeId=charge_id,
event=event
)
return processor.error_html_response(error_msg)

Troubleshooting

Common Issues

  1. KBank Record Not Found

    • Verify chargeId exists in KBank system
    • Check KBank API endpoint is accessible
    • Verify API credentials are correct
  2. Amount Mismatch

    • Check order grandTotal matches KBank paid amount
    • Tolerance is 2.0 THB for floating-point errors
    • Verify order wasn't modified after QR creation
  3. API Errors

    • Check KBank API status
    • Verify network connectivity
    • Check API credentials in Secrets Manager

Debugging

  • Check CloudWatch logs for KBank API responses
  • Review CallbackErrorLogTable for error history
  • Check Sentry for exception tracking
  • Verify QRPaymentRecordTable records are being saved
  • Test with known good charge IDs

References

  • Template.yaml: Lines 156-200
  • Related Functions: KBankCreateOrder, QRNotify, ProcessPaidOrderInternal
  • KBank QR API: /qr/v2/qr/&#123;charge_id&#125; endpoint