Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/daraja-customer-payments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'mpesa-api': minor
---

Daraja 3.0 customer payments and lookups, none of which needs an initiator:

- `qr.generate` makes Dynamic QR codes, returned as a base64 PNG.
- `b2b.expressCheckout` sends a B2B Express CheckOut USSD push to a merchant's till; read its callback with the new `parseExpressCheckoutCallback`.
- `pullTransactions.register`, `query` and `all` list the last 48 hours of C2B payments to a shortcode. `all` is an async iterator over every page.

See [Dynamic QR](https://newtonmunene99.github.io/mpesa-api/apis/qr), [B2B Express CheckOut](https://newtonmunene99.github.io/mpesa-api/apis/b2b#express-checkout) and [Pull Transactions](https://newtonmunene99.github.io/mpesa-api/apis/pull-transactions).
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ MPESA_SECURITY_CREDENTIAL=
MPESA_INITIATOR_PASSWORD=
MPESA_CERTIFICATE_PATH=

# Organisation shortcode used for C2B, B2C, Business To Pochi, B2B, Transaction Status, Account
# Balance and Reversal
# Organisation shortcode used for C2B, B2C, Business To Pochi, B2B, Pull Transactions,
# Transaction Status, Account Balance and Reversal
MPESA_B2C_SHORTCODE=
MPESA_TEST_MSISDN=254708374149

Expand Down
6 changes: 5 additions & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,22 @@ body:
- OAuth / authentication
- stkPush.send
- stkPush.query
- qr.generate
- c2b.registerUrls
- c2b.simulate
- pullTransactions.register
- pullTransactions.query / pullTransactions.all
- b2c.pay
- b2c.payToPochi
- b2b.payBill
- b2b.buyGoods
- b2b.topUpB2C
- b2b.remitTax
- b2b.expressCheckout
- accountBalance.query
- transactionStatus.query
- reversal.request
- Callback parsers (parseStkCallback, parseResult, parseC2BNotification, parseBalances)
- Callback parsers (parseStkCallback, parseResult, parseC2BNotification, parseBalances, parseExpressCheckoutCallback)
- Version 3.x (please say which method)
- Installation / types / packaging
- Other
Expand Down
1 change: 1 addition & 0 deletions .github/instructions/daraja-apis.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ applyTo: 'src/apis/**,src/callbacks/**'
- Validate every input field with the helpers in `src/core/validate.ts` (`checkLength`, `checkInt`, `checkUrl`, `checkShortCode`, `checkPhone`) and `checkParty` in `src/apis/shared.ts`. Collect every issue in one `Issues` and throw once with `issues.throwIfAny('<namespace>.<method>')` before anything is sent (`initiatorRequest` does this for initiator APIs). Report a missing passkey as an issue too.
- Map camelCase input to Daraja's exact wire names in one object literal, and map the response back with `str()` and `code()` from `core/coerce.ts`, keeping the body in `raw`. Omit optional fields that are empty rather than sending `""`.
- Initiator APIs (B2C, Business To Pochi, B2B, Transaction Status, Account Balance, Reversal and new ones like them) call `initiatorRequest` from `src/apis/initiator.ts` with their `api`, `path`, URLs and a `fields(issues)` callback that validates the API's own input and returns its Daraja fields. `initiatorRequest` checks the initiator and the URLs, gets the credential from `ctx.securityCredential`, and sends the request. Never build or log the credential yourself.
- APIs without an initiator (Dynamic QR, B2B Express CheckOut, Pull Transactions) validate with their own `Issues` and call `ctx.post` directly. `ctx.post` rejects a 2xx body whose `ResponseCode` isn't all zeros; an API whose success codes differ passes `{ success: (code) => … }` (Pull Transactions accepts `1000` and `1001`), and one that answers with another field (`ResponseStatus`, `code`) checks it itself.
- In production, URLs must be `https`; C2B registration URLs also must not contain blocked keywords (see `checkUrl`). Some APIs are sandbox-only (C2B simulate) and must say so.
- Callback parsers take `unknown` (already-parsed JSON from an untrusted sender). Use the helpers in `src/callbacks/shared.ts`: `flatten` builds prototype-less objects, `setOwn` keeps a `__proto__` key harmless, `readCents` reads money as exact integer cents (at most two decimal places, never `value * 100`), and `requireValue` rejects non-primitive values. Throw a single `ValidationError` listing every problem.
- A new endpoint, field or behaviour needs a test per validation rule, a README table update, and a changeset.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ A typed client for Safaricom's [M-Pesa Daraja 3.0 API](https://developer.safaric
Supported APIs:

- [M-Pesa Express (STK push) and its query](https://newtonmunene99.github.io/mpesa-api/apis/stk-push)
- [Dynamic QR codes](https://newtonmunene99.github.io/mpesa-api/apis/qr)
- [Customer to Business (C2B): register URLs and simulate](https://newtonmunene99.github.io/mpesa-api/apis/c2b)
- [Pull Transactions: the last 48 hours of C2B payments](https://newtonmunene99.github.io/mpesa-api/apis/pull-transactions)
- [Business to Customer (B2C) and Business To Pochi](https://newtonmunene99.github.io/mpesa-api/apis/b2c)
- [Business to Business (B2B): pay bill, buy goods, B2C account top up, tax remittance](https://newtonmunene99.github.io/mpesa-api/apis/b2b)
- [Business to Business (B2B): pay bill, buy goods, B2C account top up, tax remittance, Express CheckOut](https://newtonmunene99.github.io/mpesa-api/apis/b2b)
- [Transaction Status](https://newtonmunene99.github.io/mpesa-api/apis/transaction-status)
- [Account Balance](https://newtonmunene99.github.io/mpesa-api/apis/account-balance)
- [Reversal](https://newtonmunene99.github.io/mpesa-api/apis/reversal)
Expand Down
140 changes: 138 additions & 2 deletions __tests__/apis/b2b.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { constants, privateDecrypt } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { describe, expect, test } from 'vite-plus/test';
import { describe, expect, test, vi } from 'vite-plus/test';
import { b2b, type B2BApi } from '../../src/apis/b2b';
import { createContext, createMpesa, type MpesaConfig } from '../../src/client';
import { ValidationError } from '../../src/core/errors';
import { DarajaApiError, ValidationError } from '../../src/core/errors';
import { fakeFetch, type FakeResponse } from '../helpers/fake-fetch';
import { sandboxCapture } from '../helpers/sandbox-capture';

const read = (name: string): string =>
readFileSync(new URL(`../fixtures/certs/${name}`, import.meta.url), 'utf8');
Expand Down Expand Up @@ -413,3 +414,138 @@ describe('b2b optional-field rules per method', () => {
expect(calls).toHaveLength(0);
});
});

describe('b2b.expressCheckout', () => {
// The acknowledgement sample from the B2B Express CheckOut portal page.
const initiated = { code: '0', status: 'USSD Initiated Successfully' };
const checkout = {
shortCode: 600000,
merchantTill: 123456,
amount: 100,
paymentReference: 'INV-7',
partnerName: 'Vendor',
callbackUrl: 'https://example.com/b2b/express',
};

test('posts the push without an initiator and returns the request ID', async () => {
const { calls, api } = setup([token, { status: 200, body: initiated }], {
initiator: undefined,
});

const res = await api.expressCheckout({ ...checkout, requestRefId: 'ref-1' });

expect(calls[1]!.url).toBe('https://sandbox.safaricom.co.ke/v1/ussdpush/get-msisdn');
expect(calls[1]!.body).toEqual({
primaryShortCode: '123456',
receiverShortCode: '600000',
amount: '100',
paymentRef: 'INV-7',
callbackUrl: 'https://example.com/b2b/express',
partnerName: 'Vendor',
RequestRefID: 'ref-1',
});
expect(res).toEqual({
code: '0',
status: 'USSD Initiated Successfully',
requestRefId: 'ref-1',
raw: initiated,
});
});

test('generates a request ID when none is given', async () => {
const spy = vi.spyOn(crypto, 'randomUUID').mockReturnValue('0-0-0-0-0');
try {
const { calls, api } = setup([token, { status: 200, body: initiated }]);

const res = await api.expressCheckout(checkout);

expect(calls[1]!.body).toMatchObject({ RequestRefID: '0-0-0-0-0' });
expect(res.requestRefId).toBe('0-0-0-0-0');
} finally {
spy.mockRestore();
}
});

test('rejects a non-zero code as DarajaApiError', async () => {
const body = { code: '1', status: 'Failed' };
const { api } = setup([token, { status: 200, body }]);

const error = await api.expressCheckout(checkout).catch((e: unknown) => e);

expect(error).toBeInstanceOf(DarajaApiError);
expect(error).toMatchObject({ status: 200, errorCode: '1', errorMessage: 'Failed', body });
});

test('rejects an acknowledgement without a code', async () => {
const { api } = setup([token, { status: 200, body: { status: 'Unknown' } }]);

await expect(api.expressCheckout(checkout)).rejects.toBeInstanceOf(DarajaApiError);
});

test('surfaces the sandbox refusal as DarajaApiError', async () => {
const captured = sandboxCapture('b2b-express-checkout');
const refused: FakeResponse = { status: captured.status, body: captured.response };
// A 401 makes the SDK refresh the token and retry once before giving up.
const { api, calls } = setup([token, refused, token, refused]);

const error = await api.expressCheckout(checkout).catch((e: unknown) => e);

expect(error).toBeInstanceOf(DarajaApiError);
expect(error).toMatchObject({ status: 401, errorCode: '401' });
expect(calls).toHaveLength(4);
});

test('is wired on createMpesa', async () => {
const { fetch, calls } = fakeFetch([token, { status: 200, body: initiated }]);
const mpesa = createMpesa({
environment: 'sandbox',
consumerKey: 'key',
consumerSecret: 'secret',
fetch,
});

await mpesa.b2b.expressCheckout(checkout);

expect(calls[1]!.url).toBe('https://sandbox.safaricom.co.ke/v1/ussdpush/get-msisdn');
});

test.each([
['amount 0', { amount: 0 }, 'amount', 'must be at least 1'],
['fractional amount', { amount: 1.5 }, 'amount', 'must be an integer'],
['short shortCode', { shortCode: 12 }, 'shortCode', 'must be a 5 to 7 digit shortcode'],
[
'non-numeric merchantTill',
{ merchantTill: 'abc' as never },
'merchantTill',
'must be a 5 to 7 digit shortcode',
],
['empty paymentReference', { paymentReference: '' }, 'paymentReference', 'is required'],
['blank partnerName', { partnerName: ' ' }, 'partnerName', 'is required'],
['relative callbackUrl', { callbackUrl: '/cb' }, 'callbackUrl', 'must be an absolute URL'],
['empty requestRefId', { requestRefId: '' }, 'requestRefId', 'is required'],
['blank requestRefId', { requestRefId: ' ' }, 'requestRefId', 'is required'],
['non-string partnerName', { partnerName: 42 as never }, 'partnerName', 'is required'],
])('rejects %s', async (_, override, path, message) => {
const { api, calls } = setup([]);

const error = await api.expressCheckout({ ...checkout, ...override }).catch((e: unknown) => e);

expect(error).toBeInstanceOf(ValidationError);
expect((error as ValidationError).message).toContain('b2b.expressCheckout');
expect((error as ValidationError).issues).toEqual([{ path, message }]);
expect(calls).toHaveLength(0);
});

test('requires https callbacks in production', async () => {
const { api, calls } = setup([], { environment: 'production' });

const error = await api
.expressCheckout({ ...checkout, callbackUrl: 'http://example.com/cb' })
.catch((e: unknown) => e);

expect((error as ValidationError).issues).toEqual([
{ path: 'callbackUrl', message: 'must use https in production' },
]);
expect(calls).toHaveLength(0);
});
});
Loading
Loading