Files
buzz/lib/promptpay.test.ts
T
dmgnr a83015384c
Build and Push Image / build-backend (push) Successful in 1m17s
Build and Push Image / build-frontend (push) Successful in 3m15s
Build and Push Image / push (push) Successful in 53s
feat(db): add read replicas
2026-08-30 16:19:13 +00:00

1048 lines
30 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import {
crc16,
type DataObject,
decodeEMVCo,
decodePromptPay,
encodeEMVCo,
encodePromptPay,
formatCRC,
generatePromptPayPayload,
normalizePromptPayIdentifier,
parseEMVCo,
parsePromptPayPayload,
parseTLV,
serializeTLV,
verifyCRC,
} from "./promptpay";
const encoder = new TextEncoder();
const example =
"00020101021129370016A000000677010111021311111111111115802TH530376463047B5A";
function withCRC(payload: string): string {
const body = `${payload}6304`;
return `${body}${formatCRC(crc16(encoder.encode(body)))}`;
}
describe("serializeTLV", () => {
test("sorts tags numerically without mutating the input", () => {
const objects: DataObject[] = [
{ type: "primitive", tag: 58, value: "TH" },
{ type: "primitive", tag: 0, value: "01" },
{ type: "primitive", tag: 53, value: "764" },
];
const snapshot = structuredClone(objects);
expect(serializeTLV(objects)).toBe("00020153037645802TH");
expect(objects).toEqual(snapshot);
});
test("sorts tags inside templates", () => {
expect(
serializeTLV([
{
type: "template",
tag: 29,
value: [
{ type: "primitive", tag: 3, value: "wallet" },
{ type: "primitive", tag: 0, value: "aid" },
],
},
]),
).toBe("29170003aid0306wallet");
});
test("serializes primitive data objects", () => {
expect(
serializeTLV([
{ type: "primitive", tag: 0, value: "01" },
{ type: "primitive", tag: 58, value: "TH" },
]),
).toBe("0002015802TH");
});
test("pads single-digit tags and lengths", () => {
expect(serializeTLV([{ type: "primitive", tag: 7, value: "x" }])).toBe(
"0701x",
);
});
test("serializes nested templates", () => {
const objects: DataObject[] = [
{
type: "template",
tag: 29,
value: [
{ type: "primitive", tag: 0, value: "A000000677010111" },
{ type: "primitive", tag: 2, value: "1111111111111" },
],
},
];
expect(serializeTLV(objects)).toBe(
"29370016A00000067701011102131111111111111",
);
});
test("serializes empty input and empty values", () => {
expect(serializeTLV([])).toBe("");
expect(serializeTLV([{ type: "primitive", tag: 99, value: "" }])).toBe(
"9900",
);
});
test("accepts the maximum tag and value length", () => {
const value = "x".repeat(99);
expect(serializeTLV([{ type: "primitive", tag: 99, value }])).toBe(
`9999${value}`,
);
});
test.each([-1, 100])("rejects out-of-range tag %d", (tag) => {
expect(() =>
serializeTLV([{ type: "primitive", tag, value: "x" }]),
).toThrow("Tag 0 must be 00-99");
});
test("reports the index of an invalid tag", () => {
expect(() =>
serializeTLV([
{ type: "primitive", tag: 0, value: "01" },
{ type: "primitive", tag: 101, value: "x" },
]),
).toThrow("Tag 1 must be 00-99");
});
test("rejects values longer than the two-digit length field", () => {
expect(() =>
serializeTLV([{ type: "primitive", tag: 0, value: "x".repeat(100) }]),
).toThrow("Value 0 exceeds TLV length limit");
});
test("rejects templates whose serialized value is too long", () => {
expect(() =>
serializeTLV([
{
type: "template",
tag: 29,
value: [{ type: "primitive", tag: 0, value: "x".repeat(96) }],
},
]),
).toThrow("Value 0 exceeds TLV length limit");
});
});
describe("parseTLV", () => {
test("parses multiple primitive data objects", () => {
expect(parseTLV("00020153037645802TH")).toEqual([
{ type: "primitive", tag: 0, value: "01" },
{ type: "primitive", tag: 53, value: "764" },
{ type: "primitive", tag: 58, value: "TH" },
]);
});
test("parses empty payloads and zero-length values", () => {
expect(parseTLV("")).toEqual([]);
expect(parseTLV("9900")).toEqual([
{ type: "primitive", tag: 99, value: "" },
]);
});
test("preserves values verbatim", () => {
expect(parseTLV("1006aB 1!?")).toEqual([
{ type: "primitive", tag: 10, value: "aB 1!?" },
]);
});
test.each(["0", "000", "0001x00"])(
"rejects an incomplete header in %p",
(payload) => {
expect(() => parseTLV(payload)).toThrow(
/Malformed TLV payload: incomplete headers at index/,
);
},
);
test.each(["AA00", "0A00", " 100"])(
"rejects invalid tags in %p",
(payload) => {
expect(() => parseTLV(payload)).toThrow(
/Malformed TLV payload: invalid tag/,
);
},
);
test.each(["00AA", "000A", "00 1"])(
"rejects invalid lengths in %p",
(payload) => {
expect(() => parseTLV(payload)).toThrow(
/Malformed TLV payload: invalid length/,
);
},
);
test("rejects a value shorter than its declared length", () => {
expect(() => parseTLV("0005abc")).toThrow(
"Malformed TLV payload: expected value length 5 at index 4, but reached end of string",
);
});
test("reports malformed trailing data after valid objects", () => {
expect(() => parseTLV("000201x")).toThrow(
"Malformed TLV payload: incomplete headers at index 6",
);
});
test("round-trips serialized primitive objects", () => {
const objects: DataObject[] = [
{ type: "primitive", tag: 0, value: "01" },
{ type: "primitive", tag: 1, value: "12" },
{ type: "primitive", tag: 54, value: "125.50" },
];
expect(parseTLV(serializeTLV(objects))).toEqual(objects);
});
});
describe("parseEMVCo", () => {
test("parses merchant account information as a template", () => {
expect(parseEMVCo(example.slice(0, -8))).toEqual([
{ type: "primitive", tag: 0, value: "01" },
{ type: "primitive", tag: 1, value: "11" },
{
type: "template",
tag: 29,
value: [
{ type: "primitive", tag: 0, value: "A000000677010111" },
{ type: "primitive", tag: 2, value: "1111111111111" },
],
},
{ type: "primitive", tag: 58, value: "TH" },
{ type: "primitive", tag: 53, value: "764" },
]);
});
test("leaves non-template tags primitive", () => {
expect(parseEMVCo("0002015303764")).toEqual([
{ type: "primitive", tag: 0, value: "01" },
{ type: "primitive", tag: 53, value: "764" },
]);
});
test("rejects malformed TLV inside a template", () => {
expect(() => parseEMVCo("29050005x")).toThrow(/Malformed TLV payload/);
});
});
describe("decodeEMVCo", () => {
test("decodes the example payload", () => {
expect(decodeEMVCo(parseEMVCo(example.slice(0, -8)))).toEqual({
payloadFormatIndicator: "01",
pointOfInitiationMethod: "11",
paymentNetworkSpecific: [
{
tag: 29,
value: [
{ type: "primitive", tag: 0, value: "A000000677010111" },
{ type: "primitive", tag: 2, value: "1111111111111" },
],
},
],
transactionCurrency: "764",
countryCode: "TH",
});
});
test("decodes optional amount and dynamic initiation method", () => {
const payload = "00020101021253037645406125.505802TH";
expect(decodeEMVCo(parseEMVCo(payload))).toEqual({
payloadFormatIndicator: "01",
pointOfInitiationMethod: "12",
paymentNetworkSpecific: [],
transactionCurrency: "764",
transactionAmount: "125.50",
countryCode: "TH",
});
});
test("decodes an optional merchant category code", () => {
expect(
decodeEMVCo([
{ type: "primitive", tag: 0, value: "01" },
{ type: "primitive", tag: 52, value: "0000" },
{ type: "primitive", tag: 58, value: "TH" },
]),
).toEqual({
payloadFormatIndicator: "01",
paymentNetworkSpecific: [],
merchantCategoryCode: "0000",
countryCode: "TH",
});
});
test("allows all optional fields to be absent", () => {
expect(
decodeEMVCo([
{ type: "primitive", tag: 0, value: "01" },
{ type: "primitive", tag: 58, value: "TH" },
]),
).toEqual({
payloadFormatIndicator: "01",
countryCode: "TH",
paymentNetworkSpecific: [],
});
});
test("preserves unknown tags", () => {
expect(
decodeEMVCo([
{ type: "primitive", tag: 0, value: "01" },
{ type: "primitive", tag: 58, value: "TH" },
{ type: "primitive", tag: 59, value: "ignored" },
]),
).toEqual({
payloadFormatIndicator: "01",
countryCode: "TH",
paymentNetworkSpecific: [],
additionalData: [{ type: "primitive", tag: 59, value: "ignored" }],
});
});
test.each([
{
name: "missing payload format indicator",
objects: [{ type: "primitive", tag: 58, value: "TH" }],
},
{
name: "unsupported payload format indicator",
objects: [
{ type: "primitive", tag: 0, value: "02" },
{ type: "primitive", tag: 58, value: "TH" },
],
},
{
name: "unsupported initiation method",
objects: [
{ type: "primitive", tag: 0, value: "01" },
{ type: "primitive", tag: 1, value: "13" },
{ type: "primitive", tag: 58, value: "TH" },
],
},
{
name: "invalid currency length",
objects: [
{ type: "primitive", tag: 0, value: "01" },
{ type: "primitive", tag: 53, value: "76" },
{ type: "primitive", tag: 58, value: "TH" },
],
},
{
name: "missing country code",
objects: [{ type: "primitive", tag: 0, value: "01" }],
},
{
name: "invalid country code length",
objects: [
{ type: "primitive", tag: 0, value: "01" },
{ type: "primitive", tag: 58, value: "THA" },
],
},
] satisfies { name: string; objects: DataObject[] }[])(
"rejects $name",
({ objects }) => {
expect(() => decodeEMVCo(objects)).toThrow();
},
);
});
describe("encodeEMVCo", () => {
test("encodes a complete payload in EMVCo tag order", () => {
expect(
encodeEMVCo({
payloadFormatIndicator: "01",
pointOfInitiationMethod: "12",
paymentNetworkSpecific: [
{
tag: 29,
value: [
{ type: "primitive", tag: 0, value: "A000000677010111" },
{ type: "primitive", tag: 2, value: "0812345678" },
],
},
],
merchantCategoryCode: "0000",
transactionCurrency: "764",
transactionAmount: "125.50",
countryCode: "TH",
}),
).toEqual([
{ type: "primitive", tag: 0, value: "01" },
{ type: "primitive", tag: 1, value: "12" },
{
type: "template",
tag: 29,
value: [
{ type: "primitive", tag: 0, value: "A000000677010111" },
{ type: "primitive", tag: 2, value: "0812345678" },
],
},
{ type: "primitive", tag: 52, value: "0000" },
{ type: "primitive", tag: 53, value: "764" },
{ type: "primitive", tag: 54, value: "125.50" },
{ type: "primitive", tag: 58, value: "TH" },
]);
});
test("encodes the minimal valid payload", () => {
expect(
encodeEMVCo({
payloadFormatIndicator: "01",
paymentNetworkSpecific: [],
countryCode: "TH",
}),
).toEqual([
{ type: "primitive", tag: 0, value: "01" },
{ type: "primitive", tag: 58, value: "TH" },
]);
});
test("preserves the order and contents of payment network templates", () => {
const first: DataObject[] = [
{ type: "primitive", tag: 0, value: "network-one" },
];
const second: DataObject[] = [
{ type: "primitive", tag: 0, value: "network-two" },
{
type: "template",
tag: 80,
value: [{ type: "primitive", tag: 1, value: "nested" }],
},
];
expect(
encodeEMVCo({
payloadFormatIndicator: "01",
paymentNetworkSpecific: [
{ tag: 26, value: first },
{ tag: 51, value: second },
],
countryCode: "TH",
}),
).toEqual([
{ type: "primitive", tag: 0, value: "01" },
{ type: "template", tag: 26, value: first },
{ type: "template", tag: 51, value: second },
{ type: "primitive", tag: 58, value: "TH" },
]);
});
test("produces data objects that serialize and parse losslessly", () => {
const encoded = encodeEMVCo({
payloadFormatIndicator: "01",
pointOfInitiationMethod: "11",
paymentNetworkSpecific: [
{
tag: 29,
value: [
{ type: "primitive", tag: 0, value: "A000000677010111" },
{ type: "primitive", tag: 2, value: "1111111111111" },
],
},
],
transactionCurrency: "764",
countryCode: "TH",
});
expect(parseEMVCo(serializeTLV(encoded))).toEqual(encoded);
});
test("round-trips through decodeEMVCo", () => {
const data = {
payloadFormatIndicator: "01" as const,
pointOfInitiationMethod: "12" as const,
paymentNetworkSpecific: [
{
tag: 29,
value: [
{ type: "primitive" as const, tag: 0, value: "A000000677010111" },
{ type: "primitive" as const, tag: 2, value: "0812345678" },
],
},
],
transactionCurrency: "764",
transactionAmount: "99.95",
countryCode: "TH",
};
expect(decodeEMVCo(encodeEMVCo(data))).toEqual(data);
});
test("does not mutate its input", () => {
const data = {
payloadFormatIndicator: "01" as const,
paymentNetworkSpecific: [
{
tag: 29,
value: [{ type: "primitive" as const, tag: 0, value: "aid" }],
},
],
countryCode: "TH",
};
const snapshot = structuredClone(data);
encodeEMVCo(data);
expect(data).toEqual(snapshot);
});
test("retains additional data through decoding and encoding", () => {
const objects: DataObject[] = [
{ type: "primitive", tag: 0, value: "01" },
{ type: "primitive", tag: 58, value: "TH" },
{ type: "primitive", tag: 59, value: "MERCHANT" },
{ type: "primitive", tag: 60, value: "BANGKOK" },
];
expect(encodeEMVCo(decodeEMVCo(objects))).toEqual(objects);
});
test.each([
{
name: "missing payload format indicator",
data: { paymentNetworkSpecific: [], countryCode: "TH" },
},
{
name: "unsupported payload format indicator",
data: {
payloadFormatIndicator: "02",
paymentNetworkSpecific: [],
countryCode: "TH",
},
},
{
name: "unsupported initiation method",
data: {
payloadFormatIndicator: "01",
pointOfInitiationMethod: "13",
paymentNetworkSpecific: [],
countryCode: "TH",
},
},
{
name: "missing payment network list",
data: { payloadFormatIndicator: "01", countryCode: "TH" },
},
{
name: "invalid merchant category code length",
data: {
payloadFormatIndicator: "01",
paymentNetworkSpecific: [],
merchantCategoryCode: "000",
countryCode: "TH",
},
},
{
name: "invalid currency length",
data: {
payloadFormatIndicator: "01",
paymentNetworkSpecific: [],
transactionCurrency: "76",
countryCode: "TH",
},
},
{
name: "missing country code",
data: { payloadFormatIndicator: "01", paymentNetworkSpecific: [] },
},
{
name: "invalid country code length",
data: {
payloadFormatIndicator: "01",
paymentNetworkSpecific: [],
countryCode: "THA",
},
},
])("rejects $name", ({ data }) => {
expect(() => encodeEMVCo(data as never)).toThrow();
});
});
describe("decodePromptPay", () => {
const aid: DataObject = {
type: "primitive",
tag: 0,
value: "A000000677010111",
};
test.each([
{ tag: 1, type: "mobile", value: "0066812345678" },
{ tag: 2, type: "nationalId", value: "1234567890123" },
{ tag: 3, type: "ewallet", value: "123456789012345" },
{ tag: 4, type: "bankAccount", value: "1234567890" },
] as const)("decodes a $type identifier", ({ tag, type, value }) => {
expect(decodePromptPay([aid, { type: "primitive", tag, value }])).toEqual({
aid: "A000000677010111",
identifier: { type, value },
});
});
test("decodes merchant account information parsed from an EMVCo payload", () => {
const objects = parseEMVCo("29370016A00000067701011102131111111111111");
const merchantAccount = objects[0];
expect(merchantAccount?.type).toBe("template");
if (merchantAccount?.type !== "template") return;
expect(decodePromptPay(merchantAccount.value)).toEqual({
aid: "A000000677010111",
identifier: { type: "nationalId", value: "1111111111111" },
});
});
test("rejects multiple recognized identifiers", () => {
expect(() =>
decodePromptPay([
aid,
{ type: "primitive", tag: 4, value: "1234567890" },
{ type: "primitive", tag: 2, value: "1234567890123" },
]),
).toThrow("Expected exactly one PromptPay identifier.");
});
test("ignores unknown tags before a recognized identifier", () => {
expect(
decodePromptPay([
{ type: "primitive", tag: 99, value: "ignored" },
aid,
{ type: "primitive", tag: 2, value: "1234567890123" },
]),
).toEqual({
aid: "A000000677010111",
identifier: { type: "nationalId", value: "1234567890123" },
});
});
test("ignores template objects using identifier tags", () => {
expect(
decodePromptPay([
aid,
{
type: "template",
tag: 1,
value: [{ type: "primitive", tag: 0, value: "ignored" }],
},
{ type: "primitive", tag: 3, value: "123456789012345" },
]),
).toEqual({
aid: "A000000677010111",
identifier: { type: "ewallet", value: "123456789012345" },
});
});
test("accepts a bank account at its maximum length", () => {
const value = "1".repeat(43);
expect(
decodePromptPay([aid, { type: "primitive", tag: 4, value }]).identifier,
).toEqual({ type: "bankAccount", value });
});
test.each([
{ tag: 1, value: "1".repeat(12), name: "short mobile" },
{ tag: 1, value: "1".repeat(14), name: "long mobile" },
{ tag: 2, value: "1".repeat(12), name: "short national ID" },
{ tag: 2, value: "1".repeat(14), name: "long national ID" },
{ tag: 3, value: "1".repeat(14), name: "short e-wallet ID" },
{ tag: 3, value: "1".repeat(16), name: "long e-wallet ID" },
{ tag: 4, value: "1".repeat(44), name: "long bank account" },
])("rejects a $name", ({ tag, value }) => {
expect(() =>
decodePromptPay([aid, { type: "primitive", tag, value }]),
).toThrow();
});
test("rejects input without a recognized identifier", () => {
expect(() => decodePromptPay([aid])).toThrow(
"Expected exactly one PromptPay identifier.",
);
expect(() =>
decodePromptPay([aid, { type: "primitive", tag: 5, value: "unknown" }]),
).toThrow("Expected exactly one PromptPay identifier.");
});
test("rejects a missing or incorrect AID", () => {
const identifier: DataObject = {
type: "primitive",
tag: 2,
value: "1234567890123",
};
expect(() => decodePromptPay([identifier])).toThrow();
expect(() =>
decodePromptPay([
{ type: "primitive", tag: 0, value: "A000000000000000" },
identifier,
]),
).toThrow();
});
test("rejects a template AID", () => {
expect(() =>
decodePromptPay([
{ type: "template", tag: 0, value: [] },
{ type: "primitive", tag: 1, value: "0066812345678" },
]),
).toThrow();
});
});
describe("encodePromptPay", () => {
test.each([
{ type: "mobile", tag: 1, value: "0066812345678" },
{ type: "nationalId", tag: 2, value: "1234567890123" },
{ type: "ewallet", tag: 3, value: "123456789012345" },
] as const)(
"encodes a canonical $type identifier",
({ type, tag, value }) => {
expect(encodePromptPay({ type, value })).toEqual([
{
type: "primitive",
tag: 0,
value: "A000000677010111",
},
{ type: "primitive", tag, value },
]);
},
);
test("rejects noncanonical identifier values", () => {
expect(() =>
encodePromptPay({ type: "mobile", value: "0812345678" }),
).toThrow();
expect(() =>
encodePromptPay({ type: "nationalId", value: "123-456" }),
).toThrow();
expect(() =>
encodePromptPay({ type: "ewallet", value: "A".repeat(15) }),
).toThrow();
});
test("does not encode the reserved bank account identifier", () => {
expect(() =>
encodePromptPay({ type: "bankAccount", value: "1234567890" } as never),
).toThrow("reserved for future use");
});
});
describe("normalizePromptPayIdentifier", () => {
test.each([
["0801234567", "0066801234567"],
["080-123-4567", "0066801234567"],
["+66-89-123-4567", "0066891234567"],
["66 89 123 4567", "0066891234567"],
["0066812345678", "0066812345678"],
])("normalizes mobile %s", (value, expected) => {
expect(normalizePromptPayIdentifier({ type: "mobile", value })).toEqual({
type: "mobile",
value: expected,
});
});
test("normalizes formatted national IDs", () => {
expect(
normalizePromptPayIdentifier({
type: "nationalId",
value: "1-2345-67890-12-3",
}),
).toEqual({ type: "nationalId", value: "1234567890123" });
});
test("normalizes formatted e-wallet IDs", () => {
expect(
normalizePromptPayIdentifier({
type: "ewallet",
value: "012 345 678 901 234",
}),
).toEqual({ type: "ewallet", value: "012345678901234" });
});
test.each([
{ type: "mobile", value: "08123abc", name: "mobile with letters" },
{ type: "mobile", value: "081234567", name: "short mobile" },
{
type: "nationalId",
value: "123456789012A",
name: "national ID with letters",
},
{
type: "nationalId",
value: "123456789012",
name: "short national ID",
},
{
type: "ewallet",
value: "12345678901234",
name: "short e-wallet ID",
},
] as const)("rejects a $name", ({ type, value }) => {
expect(() => normalizePromptPayIdentifier({ type, value })).toThrow();
});
});
describe("generatePromptPayPayload", () => {
test.each([
{
name: "local mobile number",
options: {
identifier: { type: "mobile" as const, value: "0801234567" },
},
expected:
"00020101021129370016A0000006770101110113006680123456753037645802TH6304BE2B",
},
{
name: "formatted international mobile number",
options: {
identifier: { type: "mobile" as const, value: "+66-89-123-4567" },
},
expected:
"00020101021129370016A0000006770101110113006689123456753037645802TH6304F67D",
},
{
name: "national ID",
options: {
identifier: {
type: "nationalId" as const,
value: "1-1111-11111-11-1",
},
},
expected:
"00020101021129370016A0000006770101110213111111111111153037645802TH6304A4E6",
},
{
name: "tax ID",
options: {
identifier: {
type: "nationalId" as const,
value: "0123456789012",
},
},
expected:
"00020101021129370016A0000006770101110213012345678901253037645802TH6304D301",
},
{
name: "e-wallet ID",
options: {
identifier: {
type: "ewallet" as const,
value: "012345678901234",
},
},
expected:
"00020101021129390016A000000677010111031501234567890123453037645802TH6304483D",
},
{
name: "dynamic amount",
options: {
identifier: { type: "mobile" as const, value: "000-000-0000" },
amount: 4.22,
},
expected:
"00020101021229370016A00000067701011101130066000000000530376454044.225802TH63049A72",
},
])("matches the sorted $name vector", ({ options, expected }) => {
expect(generatePromptPayPayload(options)).toBe(expected);
});
test.each([
["4", "4.00"],
["10", "10.00"],
[" 10 ", "10.00"],
["4.2", "4.20"],
["4.22", "4.22"],
[4, "4.00"],
[4.2, "4.20"],
] as const)("formats amount %p as %s", (amount, expected) => {
const parsed = parsePromptPayPayload(
generatePromptPayPayload({
identifier: { type: "mobile", value: "0801234567" },
amount,
}),
);
expect(parsed.emvco.transactionAmount).toBe(expected);
expect(parsed.emvco.pointOfInitiationMethod).toBe("12");
});
test("emits an optional merchant category code", () => {
const parsed = parsePromptPayPayload(
generatePromptPayPayload({
identifier: { type: "mobile", value: "0801234567" },
merchantCategoryCode: "0000",
}),
);
expect(parsed.emvco.merchantCategoryCode).toBe("0000");
});
test.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY, "", "1.234"])(
"rejects invalid amount %p",
(amount) => {
expect(() =>
generatePromptPayPayload({
identifier: { type: "mobile", value: "0801234567" },
amount,
}),
).toThrow();
},
);
test("rejects invalid merchant category codes", () => {
expect(() =>
generatePromptPayPayload({
identifier: { type: "mobile", value: "0801234567" },
merchantCategoryCode: "123",
}),
).toThrow("must be 4 digits");
});
});
describe("parsePromptPayPayload", () => {
test("returns the raw, EMVCo, and PromptPay representations", () => {
const payload = generatePromptPayPayload({
identifier: { type: "mobile", value: "0801234567" },
amount: "19.90",
});
const parsed = parsePromptPayPayload(payload);
expect(parsed.payload).toBe(payload);
expect(parsed.dataObjects.at(-1)).toEqual({
type: "primitive",
tag: 63,
value: payload.slice(-4),
});
expect(parsed.emvco).toMatchObject({
payloadFormatIndicator: "01",
pointOfInitiationMethod: "12",
transactionCurrency: "764",
transactionAmount: "19.90",
countryCode: "TH",
});
expect(parsed.promptPay).toEqual({
aid: "A000000677010111",
identifier: { type: "mobile", value: "0066801234567" },
});
});
test("preserves unmodeled EMVCo data", () => {
const generated = generatePromptPayPayload({
identifier: { type: "nationalId", value: "1234567890123" },
});
const payload = withCRC(`${generated.slice(0, -8)}5904TEST`);
expect(parsePromptPayPayload(payload).emvco.additionalData).toEqual([
{ type: "primitive", tag: 59, value: "TEST" },
]);
});
test("rejects a corrupted checksum", () => {
const payload = generatePromptPayPayload({
identifier: { type: "mobile", value: "0801234567" },
});
expect(() => parsePromptPayPayload(`${payload.slice(0, -1)}0`)).toThrow(
"Invalid PromptPay payload CRC",
);
});
test("rejects a payload without a PromptPay template", () => {
expect(() =>
parsePromptPayPayload(withCRC("0002010102115802TH5303764")),
).toThrow("exactly one PromptPay merchant account template");
});
test("rejects a non-THB currency", () => {
const generated = generatePromptPayPayload({
identifier: { type: "mobile", value: "0801234567" },
});
const body = generated.slice(0, -8).replace("5303764", "5303840");
expect(() => parsePromptPayPayload(withCRC(body))).toThrow(
"currency must be THB",
);
});
test("rejects a non-Thai domestic country code", () => {
const generated = generatePromptPayPayload({
identifier: { type: "mobile", value: "0801234567" },
});
const body = generated.slice(0, -8).replace("5802TH", "5802US");
expect(() => parsePromptPayPayload(withCRC(body))).toThrow(
"country code must be TH",
);
});
});
describe("crc16", () => {
test("matches the CRC-16/CCITT-FALSE check value", () => {
expect(crc16(encoder.encode("123456789"))).toBe(0x29b1);
});
test("returns the initial value for empty input", () => {
expect(crc16(new Uint8Array())).toBe(0xffff);
});
test("operates on raw bytes", () => {
expect(crc16(new Uint8Array([0x00, 0xff, 0x10, 0x80]))).toBe(0xd958);
});
test("generates the CRC from the PromptPay example", () => {
const withoutCrc = example.slice(0, -4);
expect(formatCRC(crc16(encoder.encode(withoutCrc)))).toBe(
example.slice(-4),
);
});
});
describe("formatCRC", () => {
test.each([
[0x0000, "0000"],
[0x000a, "000A"],
[0x0123, "0123"],
[0xabcd, "ABCD"],
[0xffff, "FFFF"],
])("formats %d as %s", (crc, expected) => {
expect(formatCRC(crc)).toBe(expected);
});
});
describe("verifyCRC", () => {
test("verifies the PromptPay example", () => {
expect(verifyCRC(example)).toBeTrue();
});
test("verifies generated payloads", () => {
expect(verifyCRC(withCRC("0002010102125802TH"))).toBeTrue();
});
test("rejects payloads shorter than a CRC field", () => {
expect(verifyCRC("")).toBeFalse();
expect(verifyCRC("6304ABC")).toBeFalse();
});
test("rejects a missing or misplaced CRC tag", () => {
expect(verifyCRC("0002019904FFFF")).toBeFalse();
expect(verifyCRC(`${example}00`)).toBeFalse();
});
test("rejects a modified payload", () => {
const modified = `${example.slice(0, 4)}02${example.slice(6)}`;
expect(verifyCRC(modified)).toBeFalse();
});
test("rejects a modified checksum", () => {
expect(verifyCRC(`${example.slice(0, -1)}B`)).toBeFalse();
});
test("requires the canonical uppercase checksum", () => {
expect(
verifyCRC(example.slice(0, -4) + example.slice(-4).toLowerCase()),
).toBe(false);
});
});