@miniduckco/stash 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,159 @@
1
+ export type PaymentProvider = "ozow" | "payfast" | "paystack";
2
+ export type PayfastProviderOptions = {
3
+ paymentMethod?: string;
4
+ emailConfirmation?: boolean;
5
+ confirmationAddress?: string;
6
+ mPaymentId?: string;
7
+ itemName?: string;
8
+ itemDescription?: string;
9
+ };
10
+ export type PaystackProviderOptions = {
11
+ channels?: string[];
12
+ };
13
+ export type OzowProviderOptions = {
14
+ selectedBankId?: string;
15
+ customerIdentityNumber?: string;
16
+ allowVariableAmount?: boolean;
17
+ variableAmountMin?: number;
18
+ variableAmountMax?: number;
19
+ };
20
+ export type ProviderOptions = PayfastProviderOptions | OzowProviderOptions | PaystackProviderOptions;
21
+ export type OzowCredentials = {
22
+ siteCode: string;
23
+ apiKey: string;
24
+ privateKey: string;
25
+ };
26
+ export type PayfastCredentials = {
27
+ merchantId: string;
28
+ merchantKey: string;
29
+ passphrase?: string;
30
+ };
31
+ export type PaystackCredentials = {
32
+ secretKey: string;
33
+ };
34
+ export type StashConfig = {
35
+ provider: PaymentProvider;
36
+ credentials: OzowCredentials | PayfastCredentials | PaystackCredentials;
37
+ testMode?: boolean;
38
+ defaults?: {
39
+ currency?: string;
40
+ };
41
+ };
42
+ export type PaymentCreateInput = {
43
+ amount: string | number;
44
+ currency?: "ZAR" | string;
45
+ reference: string;
46
+ description?: string;
47
+ customer?: {
48
+ firstName?: string;
49
+ lastName?: string;
50
+ email?: string;
51
+ phone?: string;
52
+ };
53
+ urls?: {
54
+ returnUrl?: string;
55
+ cancelUrl?: string;
56
+ notifyUrl?: string;
57
+ errorUrl?: string;
58
+ };
59
+ metadata?: Record<string, string>;
60
+ providerOptions?: ProviderOptions;
61
+ providerData?: Record<string, string | number | boolean | null | undefined>;
62
+ };
63
+ export type Payment = {
64
+ id: string;
65
+ status: "pending" | "paid" | "failed";
66
+ amount: number;
67
+ currency: string;
68
+ redirectUrl?: string;
69
+ provider: PaymentProvider;
70
+ providerRef?: string;
71
+ raw?: unknown;
72
+ };
73
+ export type PaymentVerifyInput = {
74
+ reference: string;
75
+ };
76
+ export type VerificationResult = {
77
+ provider: PaymentProvider;
78
+ status: "pending" | "paid" | "failed" | "unknown";
79
+ providerRef?: string;
80
+ raw?: unknown;
81
+ };
82
+ export type WebhookEvent = {
83
+ type: "payment.completed" | "payment.failed" | "payment.cancelled";
84
+ data: {
85
+ id?: string;
86
+ providerRef?: string;
87
+ reference: string;
88
+ amount?: number;
89
+ currency?: string;
90
+ provider: PaymentProvider;
91
+ raw: unknown;
92
+ };
93
+ };
94
+ export type WebhookParseInput = {
95
+ provider?: PaymentProvider;
96
+ rawBody: string | Buffer;
97
+ headers?: Record<string, string | string[] | undefined>;
98
+ };
99
+ export type ParsedWebhook = {
100
+ event: WebhookEvent;
101
+ provider: PaymentProvider;
102
+ raw: Record<string, unknown>;
103
+ };
104
+ export type PaymentRequest = {
105
+ provider: PaymentProvider;
106
+ amount: string | number;
107
+ currency?: "ZAR" | string;
108
+ reference: string;
109
+ description?: string;
110
+ customer?: {
111
+ firstName?: string;
112
+ lastName?: string;
113
+ email?: string;
114
+ phone?: string;
115
+ };
116
+ urls?: {
117
+ returnUrl?: string;
118
+ cancelUrl?: string;
119
+ notifyUrl?: string;
120
+ errorUrl?: string;
121
+ };
122
+ metadata?: Record<string, string>;
123
+ secrets: {
124
+ siteCode?: string;
125
+ apiKey?: string;
126
+ privateKey?: string;
127
+ merchantId?: string;
128
+ merchantKey?: string;
129
+ passphrase?: string;
130
+ paystackSecretKey?: string;
131
+ };
132
+ providerOptions?: ProviderOptions;
133
+ testMode?: boolean;
134
+ providerData?: Record<string, string | number | boolean | null | undefined>;
135
+ };
136
+ export type PaymentResponse = {
137
+ provider: PaymentProvider;
138
+ redirectUrl: string;
139
+ method: "GET" | "POST";
140
+ formFields?: Record<string, string>;
141
+ paymentRequestId?: string;
142
+ raw?: unknown;
143
+ };
144
+ export type WebhookVerifyInput = {
145
+ provider: PaymentProvider;
146
+ rawBody?: string | Buffer;
147
+ payload?: Record<string, string | number | boolean | null | undefined>;
148
+ headers?: Record<string, string | string[] | undefined>;
149
+ secrets: {
150
+ privateKey?: string;
151
+ passphrase?: string;
152
+ paystackSecretKey?: string;
153
+ };
154
+ };
155
+ export type WebhookVerifyResult = {
156
+ provider: PaymentProvider;
157
+ isValid: boolean;
158
+ reason?: string;
159
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,112 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { test } from "node:test";
4
+ import { buildOzowHashCheck, makeOzowPayment, verifyOzowWebhook, } from "../src/providers/ozow.js";
5
+ test("buildOzowHashCheck excludes cellphone and allowVariableAmount false", () => {
6
+ const payload = {
7
+ SiteCode: "TSTSTE0001",
8
+ CountryCode: "ZA",
9
+ CurrencyCode: "ZAR",
10
+ Amount: "25.00",
11
+ TransactionReference: "123",
12
+ BankReference: "ABC123",
13
+ CancelUrl: "http://demo.ozow.com/cancel.aspx",
14
+ ErrorUrl: "http://demo.ozow.com/error.aspx",
15
+ SuccessUrl: "http://demo.ozow.com/success.aspx",
16
+ NotifyUrl: "http://demo.ozow.com/notify.aspx",
17
+ IsTest: "false",
18
+ AllowVariableAmount: "false",
19
+ CustomerCellphoneNumber: "0821234567",
20
+ };
21
+ const privateKey = "private-key";
22
+ const expectedString = "TSTSTE0001" +
23
+ "ZA" +
24
+ "ZAR" +
25
+ "25.00" +
26
+ "123" +
27
+ "ABC123" +
28
+ "http://demo.ozow.com/cancel.aspx" +
29
+ "http://demo.ozow.com/error.aspx" +
30
+ "http://demo.ozow.com/success.aspx" +
31
+ "http://demo.ozow.com/notify.aspx" +
32
+ "false" +
33
+ privateKey;
34
+ const expectedHash = createHash("sha512")
35
+ .update(expectedString.toLowerCase())
36
+ .digest("hex");
37
+ assert.equal(buildOzowHashCheck(payload, privateKey), expectedHash);
38
+ });
39
+ test("verifyOzowWebhook validates response hash", () => {
40
+ const privateKey = "private-key";
41
+ const payload = {
42
+ SiteCode: "TSTSTE0001",
43
+ TransactionId: "33857766-f29a-4a3a-a37e-66db3c42e439",
44
+ TransactionReference: "ORDER-1",
45
+ Amount: "25.00",
46
+ Status: "Complete",
47
+ Optional1: "",
48
+ Optional2: "",
49
+ Optional3: "",
50
+ Optional4: "",
51
+ Optional5: "",
52
+ CurrencyCode: "ZAR",
53
+ IsTest: "true",
54
+ StatusMessage: "Payment successful",
55
+ };
56
+ const concatenated = payload.SiteCode +
57
+ payload.TransactionId +
58
+ payload.TransactionReference +
59
+ payload.Amount +
60
+ payload.Status +
61
+ payload.Optional1 +
62
+ payload.Optional2 +
63
+ payload.Optional3 +
64
+ payload.Optional4 +
65
+ payload.Optional5 +
66
+ payload.CurrencyCode +
67
+ payload.IsTest +
68
+ payload.StatusMessage +
69
+ privateKey;
70
+ const hash = createHash("sha512")
71
+ .update(concatenated.toLowerCase())
72
+ .digest("hex");
73
+ const result = verifyOzowWebhook({
74
+ provider: "ozow",
75
+ payload: { ...payload, HashCheck: hash },
76
+ secrets: { privateKey },
77
+ });
78
+ assert.equal(result.isValid, true);
79
+ });
80
+ test("makeOzowPayment requires variable amount bounds", async () => {
81
+ await assert.rejects(() => makeOzowPayment({
82
+ provider: "ozow",
83
+ amount: "10.00",
84
+ reference: "ORDER-1",
85
+ secrets: {
86
+ siteCode: "SITE",
87
+ apiKey: "API",
88
+ privateKey: "PRIVATE",
89
+ },
90
+ providerOptions: {
91
+ allowVariableAmount: true,
92
+ },
93
+ }), /variableAmountMin is required/);
94
+ });
95
+ test("makeOzowPayment rejects providerData overlap", async () => {
96
+ await assert.rejects(() => makeOzowPayment({
97
+ provider: "ozow",
98
+ amount: "10.00",
99
+ reference: "ORDER-2",
100
+ secrets: {
101
+ siteCode: "SITE",
102
+ apiKey: "API",
103
+ privateKey: "PRIVATE",
104
+ },
105
+ providerOptions: {
106
+ selectedBankId: "BANK",
107
+ },
108
+ providerData: {
109
+ SelectedBankId: "OTHER",
110
+ },
111
+ }), /providerData overlaps providerOptions/);
112
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,80 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { test } from "node:test";
4
+ import { encodePayfastValue } from "../src/internal/encoding.js";
5
+ import { makePayfastPayment, verifyPayfastWebhook } from "../src/providers/payfast.js";
6
+ test("encodePayfastValue uses uppercase hex and plus for spaces", () => {
7
+ const value = "http://example.com/a b";
8
+ assert.equal(encodePayfastValue(value), "http%3A%2F%2Fexample.com%2Fa+b");
9
+ });
10
+ test("verifyPayfastWebhook validates ITN signature", () => {
11
+ const pairs = [
12
+ ["m_payment_id", "ORDER-100"],
13
+ ["pf_payment_id", "1089250"],
14
+ ["payment_status", "COMPLETE"],
15
+ ["item_name", "Test product"],
16
+ ["amount_gross", "200.00"],
17
+ ["merchant_id", "10000100"],
18
+ ];
19
+ const paramString = pairs
20
+ .map(([key, value]) => `${key}=${encodePayfastValue(value)}`)
21
+ .join("&");
22
+ const passphrase = "test-pass";
23
+ const signature = createHash("md5")
24
+ .update(`${paramString}&passphrase=${encodePayfastValue(passphrase)}`)
25
+ .digest("hex");
26
+ const rawBody = `${pairs
27
+ .map(([key, value]) => `${key}=${encodePayfastValue(value)}`)
28
+ .join("&")}&signature=${signature}`;
29
+ const result = verifyPayfastWebhook({
30
+ provider: "payfast",
31
+ rawBody,
32
+ secrets: { passphrase },
33
+ });
34
+ assert.equal(result.isValid, true);
35
+ });
36
+ test("makePayfastPayment maps providerOptions", () => {
37
+ const payment = makePayfastPayment({
38
+ provider: "payfast",
39
+ amount: "100.00",
40
+ reference: "ORDER-1",
41
+ description: "Order #1",
42
+ secrets: {
43
+ merchantId: "merchant",
44
+ merchantKey: "key",
45
+ },
46
+ providerOptions: {
47
+ paymentMethod: "cc",
48
+ emailConfirmation: true,
49
+ confirmationAddress: "ops@example.com",
50
+ mPaymentId: "MP-1",
51
+ itemName: "Custom Item",
52
+ itemDescription: "Custom Desc",
53
+ },
54
+ });
55
+ assert.equal(payment.formFields?.payment_method, "cc");
56
+ assert.equal(payment.formFields?.email_confirmation, "1");
57
+ assert.equal(payment.formFields?.confirmation_address, "ops@example.com");
58
+ assert.equal(payment.formFields?.m_payment_id, "MP-1");
59
+ assert.equal(payment.formFields?.item_name, "Custom Item");
60
+ assert.equal(payment.formFields?.item_description, "Custom Desc");
61
+ });
62
+ test("makePayfastPayment rejects providerData overlap", () => {
63
+ assert.throws(() => {
64
+ makePayfastPayment({
65
+ provider: "payfast",
66
+ amount: "100.00",
67
+ reference: "ORDER-2",
68
+ secrets: {
69
+ merchantId: "merchant",
70
+ merchantKey: "key",
71
+ },
72
+ providerOptions: {
73
+ paymentMethod: "cc",
74
+ },
75
+ providerData: {
76
+ payment_method: "dc",
77
+ },
78
+ });
79
+ }, /providerData overlaps providerOptions/);
80
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,209 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash, createHmac } from "node:crypto";
3
+ import { test } from "node:test";
4
+ import { createStash } from "../src/index.js";
5
+ import { encodePayfastValue } from "../src/internal/encoding.js";
6
+ test("createStash payments.create returns canonical payment", async () => {
7
+ const stash = createStash({
8
+ provider: "payfast",
9
+ credentials: {
10
+ merchantId: "merchant",
11
+ merchantKey: "key",
12
+ },
13
+ testMode: true,
14
+ });
15
+ const payment = await stash.payments.create({
16
+ amount: "100.00",
17
+ currency: "ZAR",
18
+ reference: "ORDER-1",
19
+ });
20
+ assert.equal(payment.status, "pending");
21
+ assert.equal(payment.currency, "ZAR");
22
+ assert.equal(payment.provider, "payfast");
23
+ assert.match(payment.id, /^[0-9a-f-]{36}$/i);
24
+ assert.ok(payment.redirectUrl);
25
+ });
26
+ test("createStash payments.create maps ozow response", async () => {
27
+ const originalFetch = globalThis.fetch;
28
+ globalThis.fetch = (async () => {
29
+ return {
30
+ ok: true,
31
+ json: async () => ({
32
+ PaymentUrl: "https://pay.ozow.com/123",
33
+ PaymentRequestId: "REQ-1",
34
+ }),
35
+ };
36
+ });
37
+ const stash = createStash({
38
+ provider: "ozow",
39
+ credentials: {
40
+ siteCode: "SITE",
41
+ apiKey: "API",
42
+ privateKey: "PRIVATE",
43
+ },
44
+ });
45
+ const payment = await stash.payments.create({
46
+ amount: "10.00",
47
+ reference: "ORDER-2",
48
+ });
49
+ assert.equal(payment.provider, "ozow");
50
+ assert.equal(payment.providerRef, "REQ-1");
51
+ assert.equal(payment.redirectUrl, "https://pay.ozow.com/123");
52
+ globalThis.fetch = originalFetch;
53
+ });
54
+ test("createStash payments.create maps paystack response", async () => {
55
+ const originalFetch = globalThis.fetch;
56
+ globalThis.fetch = (async () => {
57
+ return {
58
+ ok: true,
59
+ json: async () => ({
60
+ status: true,
61
+ data: {
62
+ authorization_url: "https://checkout.paystack.com/abc",
63
+ reference: "REF-1",
64
+ },
65
+ }),
66
+ };
67
+ });
68
+ const stash = createStash({
69
+ provider: "paystack",
70
+ credentials: {
71
+ secretKey: "sk_test",
72
+ },
73
+ });
74
+ const payment = await stash.payments.create({
75
+ amount: 2500,
76
+ reference: "REF-1",
77
+ customer: {
78
+ email: "buyer@example.com",
79
+ },
80
+ });
81
+ assert.equal(payment.provider, "paystack");
82
+ assert.equal(payment.providerRef, "REF-1");
83
+ assert.equal(payment.redirectUrl, "https://checkout.paystack.com/abc");
84
+ globalThis.fetch = originalFetch;
85
+ });
86
+ test("createStash payments.create rejects paystack major units", async () => {
87
+ const stash = createStash({
88
+ provider: "paystack",
89
+ credentials: {
90
+ secretKey: "sk_test",
91
+ },
92
+ });
93
+ await assert.rejects(() => stash.payments.create({
94
+ amount: "10.00",
95
+ reference: "REF-2",
96
+ customer: {
97
+ email: "buyer@example.com",
98
+ },
99
+ }), /minor units/);
100
+ });
101
+ test("webhooks.parse returns canonical event for payfast", () => {
102
+ const stash = createStash({
103
+ provider: "payfast",
104
+ credentials: {
105
+ merchantId: "merchant",
106
+ merchantKey: "key",
107
+ passphrase: "test-pass",
108
+ },
109
+ });
110
+ const pairs = [
111
+ ["m_payment_id", "ORDER-100"],
112
+ ["pf_payment_id", "1089250"],
113
+ ["payment_status", "COMPLETE"],
114
+ ["amount_gross", "200.00"],
115
+ ["merchant_id", "10000100"],
116
+ ];
117
+ const paramString = pairs
118
+ .map(([key, value]) => `${key}=${encodePayfastValue(value)}`)
119
+ .join("&");
120
+ const signature = createHash("md5")
121
+ .update(`${paramString}&passphrase=${encodePayfastValue("test-pass")}`)
122
+ .digest("hex");
123
+ const rawBody = `${pairs
124
+ .map(([key, value]) => `${key}=${encodePayfastValue(value)}`)
125
+ .join("&")}&signature=${signature}`;
126
+ const parsed = stash.webhooks.parse({ rawBody });
127
+ assert.equal(parsed.event.type, "payment.completed");
128
+ assert.equal(parsed.event.data.reference, "ORDER-100");
129
+ assert.equal(parsed.provider, "payfast");
130
+ });
131
+ test("webhooks.parse throws invalid_signature for payfast", () => {
132
+ const stash = createStash({
133
+ provider: "payfast",
134
+ credentials: {
135
+ merchantId: "merchant",
136
+ merchantKey: "key",
137
+ passphrase: "test-pass",
138
+ },
139
+ });
140
+ const rawBody = "payment_status=COMPLETE";
141
+ assert.throws(() => stash.webhooks.parse({ rawBody }), (error) => {
142
+ const stashError = error;
143
+ return stashError.code === "invalid_signature";
144
+ });
145
+ });
146
+ test("webhooks.parse returns canonical event for paystack", () => {
147
+ const stash = createStash({
148
+ provider: "paystack",
149
+ credentials: {
150
+ secretKey: "sk_test",
151
+ },
152
+ });
153
+ const payload = {
154
+ event: "charge.success",
155
+ data: {
156
+ reference: "REF-3",
157
+ id: 555,
158
+ amount: 2500,
159
+ currency: "ZAR",
160
+ },
161
+ };
162
+ const rawBody = JSON.stringify(payload);
163
+ const signature = createHmac("sha512", "sk_test")
164
+ .update(rawBody, "utf8")
165
+ .digest("hex");
166
+ const parsed = stash.webhooks.parse({
167
+ rawBody,
168
+ headers: {
169
+ "x-paystack-signature": signature,
170
+ },
171
+ });
172
+ assert.equal(parsed.provider, "paystack");
173
+ assert.equal(parsed.event.type, "payment.completed");
174
+ assert.equal(parsed.event.data.reference, "REF-3");
175
+ });
176
+ test("payments.verify throws unsupported_capability for payfast", async () => {
177
+ const stash = createStash({
178
+ provider: "payfast",
179
+ credentials: {
180
+ merchantId: "merchant",
181
+ merchantKey: "key",
182
+ },
183
+ });
184
+ await assert.rejects(() => stash.payments.verify({ reference: "ORDER-1" }), (error) => error.code === "unsupported_capability");
185
+ });
186
+ test("payments.verify returns paid for paystack", async () => {
187
+ const originalFetch = globalThis.fetch;
188
+ globalThis.fetch = (async () => {
189
+ return {
190
+ ok: true,
191
+ json: async () => ({
192
+ status: true,
193
+ data: {
194
+ status: "success",
195
+ id: 123,
196
+ },
197
+ }),
198
+ };
199
+ });
200
+ const stash = createStash({
201
+ provider: "paystack",
202
+ credentials: {
203
+ secretKey: "sk_test",
204
+ },
205
+ });
206
+ const result = await stash.payments.verify({ reference: "REF-1" });
207
+ assert.equal(result.status, "paid");
208
+ globalThis.fetch = originalFetch;
209
+ });
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@miniduckco/stash",
3
+ "version": "0.1.1",
4
+ "description": "integrate payments. switch once.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^22.10.2",
23
+ "typescript": "^5.4.5"
24
+ },
25
+ "scripts": {
26
+ "build": "tsc",
27
+ "test": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\" && npm run build && node --test dist/test/*.test.js",
28
+ "site:dev": "npm run dev --prefix site -- --host 0.0.0.0 --port 5173",
29
+ "site:build": "npm run build --prefix site",
30
+ "site:preview": "npm run preview --prefix site"
31
+ }
32
+ }