1048 lines
30 KiB
TypeScript
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);
|
|
});
|
|
});
|