This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.
Project Overview
PayKit SDK is an open-source, unified TypeScript payment toolkit. It provides developers with one consistent API across multiple payment providers (like Stripe, Paystack, PayPal, and more). This means that switching or adding a provider doesn't require rewriting complex billing logic. Architecturally, it's structured as a pnpm monorepo with a core package (@paykit-sdk/core) and separate provider packages (like @paykit-sdk/stripe, @paykit-sdk/paystack).
My connection to PayKit is actually quite personal, despite this being my first open-source contribution. I met the creator back while i was still in school last year when paykit was just getting traction. We reconnected recently, and he asked me to audit the Paystack provider. Apparently no one had fully tested it against the real API yet, and he wanted it solid enough to eventually pitch for listing in Paystack's own integration docs.
Bug Fix or Performance Improvement
I noticed some bizarre, silent failures happening specifically with the Paystack provider implementation when trying to integrate it. The Paystack provider's unit tests were passing, but they were only testing against hand-written mock responses β nobody had checked those mocks actually matched what Paystack's real sandbox API returns. So I went method by method: createCustomer, createSubscription, createRefund, cancelSubscription, and the rest, running each one against a real Paystack sandbox key and comparing the real response shape to what the code expected.
That surfaced four real bugs hiding behind passing tests:
-
A mapper mismatch that silently nulled out data. The core SDK's
unwrap()helper converts API responses fromsnake_casetocamelCasebefore handing them off. But the Paystack schema mappers (likeCustomer$inboundSchema) were written to read rawsnake_casefields (customer_code,created_at, etc.). Sinceunwrap()ran first, those fields were already renamed to camelCase by the time the mapper looked for them β so it found nothing. The result: customer IDs came backundefined, and timestamps came back asInvalid Date, with no error thrown anywhere. -
A hardcoded currency on refunds.
createRefundhad a hardcoded'NGN'currency fallback when building the refund response, which silently produced the wrong currency for any GHS, USD, or ZAR transaction. -
A silently swallowed cancellation failure. If Paystack's
POST /subscription/disablecall actually failed,cancelSubscriptiondidn't check the response β it just returned a fake{ status: 'canceled' }regardless, leaving the subscription still active with no error surfaced. -
Inconsistent timestamp formats. Paystack returns
created_atfromGETendpoints butcreatedAtfrom somePOST/PUTendpoints, and the schema only handled one of the two.
None of this showed up in CI, because the mock data in the existing tests had (unknowingly) baked in the same wrong assumptions as the code itself.
Code
Full PR here: payrouteshq/paykit-sdk#59 β officially approved and merged!
Here is the core of the fix to stop the silent data loss. createCustomer before:
// BEFORE (Broken):
const customer = await this.unwrap(response, 'createCustomer');
return Customer$inboundSchema(customer); // schema fails to find customer_code
unwrap() applies _toCamel, which renames customer_code β customerCode, first_name β firstName, and so on β but Customer$inboundSchema is written to read the raw, un-renamed Paystack field names. So by the time the schema ran, every field it looked for had already been renamed out from under it.
After β a new _throwIfFailed helper that checks the response succeeded but skips the camelCase step entirely, handing the mapper the raw payload it actually expects:
// AFTER (Fixed):
// Do NOT use unwrap() here β it applies _toCamel which renames customer_code
// to customerCode, first_name to firstName, etc., breaking Customer$inboundSchema.
return Customer$inboundSchema(
this._throwIfFailed(response, 'createCustomer'),
);
/**
* Like unwrap() but does NOT apply _toCamel β use this when the result will
* be handed directly to a provider-specific mapper (Customer$inboundSchema,
* Subscription$inboundSchema, Refund$inboundSchema, etc.) that reads raw
* snake_case field names from the Paystack API response.
*/
private _throwIfFailed<T>(
result: { ok: boolean; value?: PaystackResponse<T>; error?: unknown },
operation: string,
): T {
if (!result.ok || !result.value?.status) {
throw new OperationFailedError(operation, this.providerName, {
cause: new Error(result.value?.message ?? JSON.stringify(result.error) ?? 'Unknown error'),
});
}
return result.value!.data;
}
The same swap was applied everywhere unwrap() was incorrectly feeding a raw-field-expecting mapper: updateCustomer, createSubscription, cancelSubscription, and createRefund.
My Improvements
To completely clear the lineup of these bugs, I took a multi-step architectural approach:
1. Stopped double-processing the response. For create/update methods, I replaced the generic unwrap() call with a new _throwIfFailed() helper that checks for a failed response but passes the raw snake_case payload straight to the Zod mapper, instead of letting unwrap() camelCase it first. That alone fixed the undefined-IDs and invalid-timestamp bugs across every affected method.
2. Made refund currency dynamic. createRefund now reads the currency directly off the actual Paystack refund response instead of assuming NGN, so refunds on other supported currencies map correctly.
3. Added a real failure guard on subscription cancellation. If the disable call comes back unsuccessful, the method now throws an OperationFailedError instead of quietly reporting success.
4. Normalized the timestamp handling. The schema now accepts both formats Paystack actually sends, depending on which endpoint responded.
5. Closed the test gap for real. To ensure these bugs never return to the stage, I added 27 new unit tests covering every previously-untested method (customer, subscription, payment, refund, checkout). Each is mocked with response shapes I'd actually verified against the live sandbox β not guessed. Every single provider method now has comprehensive coverage.
One decision I want to call out. After I thought everything was done, I double-checked myself, and Antigravity, before pushing β more on that below.
Best Use of Google AI
I used the Antigravity AI Agent powered by Google Gemini as an active pair-programmer for this massive debugging and refactoring session, not just autocomplete. Two moments from the actual session stand out.
I asked it to double-check itself, and it found a real gap. After Gemini told me the work was fully done β bugs fixed, tests passing, changeset written β I pushed back with a plain "hold on, has everything been done?" It re-checked its own output against the plan it had written earlier and found that it had listed createRefund in the smoke-test sequence in PLAN.md, but never actually implemented that case in the script. It added the missing test (with a try/catch to handle Paystack's "transaction already fully refunded" response, since sandbox transactions can only be refunded once) and amended the commit before I moved on. I didn't spot that gap myself β I caught it by making a habit of questioning "done," and the agent did the actual re-verification.
It caught a downstream detail I had forgotten to ask for. Partway through wrap-up, I remembered the maintainer had also asked for the Paystack docs in apps/docs to be updated to match the fixes. The Gemini-powered agent updated paystack.mdx for the corrected metadata/reason fields, and on its own noticed a subtler consequence: since merchant_note is now populated from reason internally, it adjusted the type so merchant_note shows correctly as optional in provider_metadata β and committed that as its own separate, scoped commit rather than folding it silently into the bug-fix commit.
Live Sandbox Diagnostics: It was also extremely instrumental in executing curl requests to the live Paystack sandbox right inside my terminal. We used this to analyze the JSON shapes and pinpoint the exact mismatch between _toCamel and the Zod schemas.
Ultimately, what made this collaboration so powerful wasn't just the AI writing codeβit was the synergy between human oversight and agentic execution. The Gemini-powered agent didn't proactively catch those two gaps on its own. I had to drive the architecture and prompt it to re-verify its work. But once I pointed it in the right direction, it executed the fixes with incredible speed and precision. For my first open-source contribution, having an AI partner that flawlessly handles the heavy lifting while I steer the ship is exactly what made this massive PR possible in the time I had.



Top comments (0)