Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
"import": "./dist/admin/entry.js",
"types": "./dist/admin/entry.d.ts"
},
"./payment-provider": {
"import": "./dist/payment-provider.js",
"types": "./dist/payment-provider.d.ts"
},
"./astro": "./src/astro/index.ts",
"./astro/components/*": "./src/astro/components/*",
"./astro/islands/*": "./src/astro/islands/*"
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/payment-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Public entry point for this fork's PaymentProvider abstraction —
* `@dashcommerce/core/payment-provider` export. Sibling gateway
* packages (e.g. a gateway package (e.g. Paystack)) import types from
* here and call `registerPaymentProvider` at their own module load time.
*/
export type {
CreateRefundInput,
InitCheckoutInput,
InitCheckoutResult,
Money,
NormalizedPaymentEvent,
PaymentProvider,
PaymentProviderAddress,
PaymentProviderCredentials,
PaymentProviderCustomer,
PaymentProviderLineItem,
PaymentProviderRuntimeContext,
RefundResult,
VerifyWebhookInput,
VerifyWebhookResult,
} from "./payment-provider/types";
export { stripePaymentProvider } from "./payment-provider/stripe-provider";
export { createMockPaymentProvider } from "./payment-provider/mock-provider";
export {
getPaymentProvider,
listPaymentProviders,
registerPaymentProvider,
resolveProvider,
type PaymentProviderId,
} from "./payment-provider/registry";
101 changes: 101 additions & 0 deletions packages/core/src/payment-provider/mock-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// mock-provider.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { createMockPaymentProvider } from "./mock-provider";
import type { PaymentProviderCredentials, PaymentProviderRuntimeContext } from "./types";

const ctx: PaymentProviderRuntimeContext = {
http: { fetch },
log: { info: () => {}, warn: () => {}, error: () => {} },
};

const creds: PaymentProviderCredentials = { secretKey: "sk_test_mock" };

test("mock: initCheckout succeeds by default and echoes orderDraftId into the reference", async () => {
const provider = createMockPaymentProvider();
const result = await provider.initCheckout(
ctx,
{
orderDraftId: "d1",
amount: 1000,
currency: "KES",
customer: { email: "x@example.com" },
lineItems: [{ name: "Jersey", amount: 1000, currency: "KES", quantity: 1 }],
successUrl: "https://example.com/thank-you",
cancelUrl: "https://example.com/checkout",
},
creds,
);
assert.equal(result.providerReference, "mock_ref_d1");
assert.match(result.redirectUrl, /mock=1$/);
});

test("mock: initCheckout can be forced to fail for negative-path tests", async () => {
const provider = createMockPaymentProvider({ failInit: true });
await assert.rejects(() =>
provider.initCheckout(
ctx,
{
orderDraftId: "d2",
amount: 1000,
currency: "KES",
customer: { email: "x@example.com" },
lineItems: [],
successUrl: "https://example.com/thank-you",
cancelUrl: "https://example.com/checkout",
},
creds,
),
);
});

test("mock: verifyWebhook only accepts the fixture 'test-secret'", async () => {
const provider = createMockPaymentProvider();
const ok = await provider.verifyWebhook({
rawBody: "{}",
signatureHeader: "x",
secret: "test-secret",
});
assert.equal(ok.ok, true);
const bad = await provider.verifyWebhook({
rawBody: "{}",
signatureHeader: "x",
secret: "wrong",
});
assert.equal(bad.ok, false);
});

test("mock: parseWebhookEvent round-trips charge.succeeded", () => {
const provider = createMockPaymentProvider();
const event = provider.parseWebhookEvent(
JSON.stringify({
type: "charge.succeeded",
orderDraftId: "d3",
providerReference: "ref3",
amount: 500,
currency: "KES",
email: "y@example.com",
}),
);
assert.equal(event.type, "charge.succeeded");
if (event.type === "charge.succeeded") {
assert.equal(event.orderDraftId, "d3");
assert.equal(event.amount, 500);
}
});

test("mock: refund succeeds by default, can be forced to fail", async () => {
const provider = createMockPaymentProvider();
const refund = await provider.refund(ctx, { providerReference: "ref3", currency: "KES" }, creds);
assert.equal(refund.status, "succeeded");

const failing = createMockPaymentProvider({ failRefund: true });
await assert.rejects(() =>
failing.refund(ctx, { providerReference: "ref3", currency: "KES" }, creds),
);
});

test("mock: formatAmount", () => {
const provider = createMockPaymentProvider();
assert.equal(provider.formatAmount({ amount: 250000, currency: "KES" }), "KES 2500.00");
});
109 changes: 109 additions & 0 deletions packages/core/src/payment-provider/mock-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* MockPaymentProvider — an in-memory PaymentProvider for tests and for
* running the whole checkout flow with no real gateway credentials at
* all. Used by this fork's own test suite and by
* a gateway package (e.g. Paystack)'s fixture suite
* (BUILD_PLAN.md §3: "no Paystack keys in env -> implement + unit-test
* against recorded fixtures and a MockPaymentProvider").
*
* Deterministic: `initCheckout` always succeeds and returns a predictable
* reference; webhook verification passes iff `secret === "test-secret"`
* (matching whatever the test fixture sets up); refunds always succeed.
* Nothing here talks to the network.
*/

import type {
CreateRefundInput,
InitCheckoutInput,
InitCheckoutResult,
Money,
NormalizedPaymentEvent,
PaymentProvider,
RefundResult,
VerifyWebhookInput,
VerifyWebhookResult,
} from "./types";

export interface MockPaymentProviderOptions {
/** Force initCheckout/refund to fail, for negative-path tests. */
failInit?: boolean;
failRefund?: boolean;
}

export function createMockPaymentProvider(
options: MockPaymentProviderOptions = {},
): PaymentProvider {
return {
id: "mock",
label: "Mock (test-only)",

supportedCurrencies(): string[] {
return ["KES", "USD"];
},

async initCheckout(_ctx, input: InitCheckoutInput): Promise<InitCheckoutResult> {
if (options.failInit) {
throw new Error("MockPaymentProvider: forced initCheckout failure");
}
return {
providerReference: `mock_ref_${input.orderDraftId}`,
redirectUrl: `${input.successUrl}&mock=1`,
};
},

async verifyWebhook(input: VerifyWebhookInput): Promise<VerifyWebhookResult> {
if (input.secret !== "test-secret") {
return { ok: false, reason: "mock: secret mismatch" };
}
return { ok: true };
},

parseWebhookEvent(rawBody: string): NormalizedPaymentEvent {
const event = JSON.parse(rawBody) as {
type: string;
orderDraftId?: string;
providerReference?: string;
amount?: number;
currency?: string;
email?: string;
};
if (event.type === "charge.succeeded") {
return {
type: "charge.succeeded",
orderDraftId: event.orderDraftId ?? "",
providerReference: event.providerReference ?? "mock_ref",
amount: event.amount ?? 0,
currency: event.currency ?? "KES",
customer: { email: event.email ?? "test@example.com" },
channel: "Mock",
raw: event,
};
}
if (event.type === "charge.failed") {
return {
type: "charge.failed",
orderDraftId: event.orderDraftId ?? "",
providerReference: event.providerReference ?? "mock_ref",
raw: event,
};
}
return { type: "unhandled", providerEventType: event.type, raw: event };
},

async refund(_ctx, input: CreateRefundInput): Promise<RefundResult> {
if (options.failRefund) {
throw new Error("MockPaymentProvider: forced refund failure");
}
return {
providerRefundId: `mock_refund_${input.providerReference}`,
status: "succeeded",
amount: input.amount ?? 0,
currency: input.currency,
};
},

formatAmount(money: Money): string {
return `${money.currency} ${(money.amount / 100).toFixed(2)}`;
},
};
}
62 changes: 62 additions & 0 deletions packages/core/src/payment-provider/registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// registry.test.ts — plain node:test, same convention as
// emdash-sports/emdash-roles. Run with: npx tsx --test src/**/*.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import {
registerPaymentProvider,
getPaymentProvider,
listPaymentProviders,
resolveProvider,
} from "./registry";
import { createMockPaymentProvider } from "./mock-provider";
import { stripePaymentProvider } from "./stripe-provider";

class FakeKV {
constructor(private store: Record<string, unknown> = {}) {}
async get<T>(key: string): Promise<T | null> {
return (this.store[key] as T) ?? null;
}
}

test("registry: stripe is registered by default", () => {
assert.ok(getPaymentProvider("stripe"));
assert.equal(getPaymentProvider("stripe")?.label, "Stripe");
});

test("registry: registerPaymentProvider adds a new provider without clobbering existing ones", () => {
const before = listPaymentProviders().length;
registerPaymentProvider(createMockPaymentProvider());
assert.ok(getPaymentProvider("mock"));
assert.ok(listPaymentProviders().length >= before);
assert.ok(getPaymentProvider("stripe"), "registering mock must not remove stripe");
});

test("resolveProvider: defaults to stripe when settings:paymentProvider unset", async () => {
const kv = new FakeKV();
const provider = await resolveProvider(kv);
assert.equal(provider.id, "stripe");
});

test("resolveProvider: honours settings:paymentProvider when set to a registered id", async () => {
registerPaymentProvider(createMockPaymentProvider());
const kv = new FakeKV({ "settings:paymentProvider": "mock" });
const provider = await resolveProvider(kv);
assert.equal(provider.id, "mock");
});

test("resolveProvider: throws a clear error for an unregistered provider id (never silently no-ops)", async () => {
const kv = new FakeKV({ "settings:paymentProvider": "flutterwave" });
await assert.rejects(() => resolveProvider(kv), /flutterwave/);
});

test("stripePaymentProvider: identity + currency policy", () => {
assert.equal(stripePaymentProvider.id, "stripe");
assert.deepEqual(stripePaymentProvider.supportedCurrencies(), ["*"]);
});

test("stripePaymentProvider: formatAmount renders major units with currency code", () => {
assert.equal(
stripePaymentProvider.formatAmount({ amount: 150000, currency: "kes" }),
"KES 1500.00",
);
});
55 changes: 55 additions & 0 deletions packages/core/src/payment-provider/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Provider registry + runtime selection. `routes/checkout.ts` and
* `routes/webhook.ts` call `resolveProvider(ctx)` instead of importing
* `../stripe/*` directly — that one seam is the entire fork.
*
* Selection is by `settings:paymentProvider` (plugin KV), defaulting to
* "stripe" for backwards compatibility with any existing upstream
* install. Site operators switch it via the plugin's settings admin
* page (or directly via KV during test-mode bring-up).
*/

import type { PaymentProvider } from "./types";
import { stripePaymentProvider } from "./stripe-provider";

export type PaymentProviderId = "stripe" | "paystack" | "mock";

const registry = new Map<string, PaymentProvider>();
registry.set("stripe", stripePaymentProvider);

/** Sibling packages (e.g. a gateway package (e.g. Paystack)) register
* themselves here at import time so this fork's core never needs a
* hard dependency on every gateway package that exists. */
export function registerPaymentProvider(provider: PaymentProvider): void {
registry.set(provider.id, provider);
}

export function getPaymentProvider(id: string): PaymentProvider | undefined {
return registry.get(id);
}

export function listPaymentProviders(): PaymentProvider[] {
return Array.from(registry.values());
}

interface KVLike {
get<T>(key: string): Promise<T | null>;
}

const DEFAULT_PROVIDER_ID: PaymentProviderId = "stripe";

/** Resolve the active provider for this deployment from plugin KV. Falls
* back to "stripe" (upstream's only option) if unset, and throws a clear
* error if the configured id was never registered (e.g. paystack package
* not imported into astro.config.mjs) rather than silently no-op-ing. */
export async function resolveProvider(kv: KVLike): Promise<PaymentProvider> {
const configured = (await kv.get<string>("settings:paymentProvider")) ?? DEFAULT_PROVIDER_ID;
const provider = registry.get(configured);
if (!provider) {
throw new Error(
`dashcommerce: settings:paymentProvider is "${configured}" but no PaymentProvider with that id is registered. ` +
`Registered: ${Array.from(registry.keys()).join(", ") || "(none)"}.`,
);
}
return provider;
}
Loading