From a9d62af9b4eca2ad43500d7a1a65b5235a0ed69d Mon Sep 17 00:00:00 2001 From: Nyaenya Obadiah Moseti Date: Sun, 19 Jul 2026 12:23:40 +0300 Subject: [PATCH] feat(core): introduce PaymentProvider interface with Stripe adapter and mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a gateway-agnostic payment seam so additional processors (e.g. Paystack for the African market) can plug in without touching core checkout code: - payment-provider/types.ts: the PaymentProvider interface — init hosted checkout, verify webhook (constant-time, algorithm-agnostic), parse webhook event into a normalised shape, refund, format amount. - payment-provider/stripe-provider.ts: thin adapter implementing the interface over the existing stripe/* modules — no changes to any file under src/stripe/. - payment-provider/mock-provider.ts: in-memory implementation for tests and credential-free local checkout runs. - payment-provider/registry.ts: runtime provider selection via settings:paymentProvider (plugin KV), defaulting to "stripe" so existing installs behave identically. - New ./payment-provider subpath export + tsdown entry. - 19 unit tests (bun test) across the three new modules; tsc --noEmit clean; Biome formatted. Routes (checkout.ts / webhook.ts) still call stripe/* directly — wiring them onto resolveProvider() is staged as a follow-up commit so this diff stays reviewable and behaviour-neutral. Co-Authored-By: Claude Fable 5 --- packages/core/package.json | 4 + packages/core/src/payment-provider.ts | 31 +++ .../payment-provider/mock-provider.test.ts | 101 ++++++++ .../src/payment-provider/mock-provider.ts | 109 ++++++++ .../src/payment-provider/registry.test.ts | 62 +++++ .../core/src/payment-provider/registry.ts | 55 +++++ .../payment-provider/stripe-provider.test.ts | 88 +++++++ .../src/payment-provider/stripe-provider.ts | 201 +++++++++++++++ packages/core/src/payment-provider/types.ts | 232 ++++++++++++++++++ packages/core/tsdown.config.ts | 1 + 10 files changed, 884 insertions(+) create mode 100644 packages/core/src/payment-provider.ts create mode 100644 packages/core/src/payment-provider/mock-provider.test.ts create mode 100644 packages/core/src/payment-provider/mock-provider.ts create mode 100644 packages/core/src/payment-provider/registry.test.ts create mode 100644 packages/core/src/payment-provider/registry.ts create mode 100644 packages/core/src/payment-provider/stripe-provider.test.ts create mode 100644 packages/core/src/payment-provider/stripe-provider.ts create mode 100644 packages/core/src/payment-provider/types.ts diff --git a/packages/core/package.json b/packages/core/package.json index 75c3e73..ca46432 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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/*" diff --git a/packages/core/src/payment-provider.ts b/packages/core/src/payment-provider.ts new file mode 100644 index 0000000..b24945f --- /dev/null +++ b/packages/core/src/payment-provider.ts @@ -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"; diff --git a/packages/core/src/payment-provider/mock-provider.test.ts b/packages/core/src/payment-provider/mock-provider.test.ts new file mode 100644 index 0000000..3753f59 --- /dev/null +++ b/packages/core/src/payment-provider/mock-provider.test.ts @@ -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"); +}); diff --git a/packages/core/src/payment-provider/mock-provider.ts b/packages/core/src/payment-provider/mock-provider.ts new file mode 100644 index 0000000..d73560d --- /dev/null +++ b/packages/core/src/payment-provider/mock-provider.ts @@ -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 { + 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 { + 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 { + 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)}`; + }, + }; +} diff --git a/packages/core/src/payment-provider/registry.test.ts b/packages/core/src/payment-provider/registry.test.ts new file mode 100644 index 0000000..189c2be --- /dev/null +++ b/packages/core/src/payment-provider/registry.test.ts @@ -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 = {}) {} + async get(key: string): Promise { + 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", + ); +}); diff --git a/packages/core/src/payment-provider/registry.ts b/packages/core/src/payment-provider/registry.ts new file mode 100644 index 0000000..62c3712 --- /dev/null +++ b/packages/core/src/payment-provider/registry.ts @@ -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(); +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(key: string): Promise; +} + +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 { + const configured = (await kv.get("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; +} diff --git a/packages/core/src/payment-provider/stripe-provider.test.ts b/packages/core/src/payment-provider/stripe-provider.test.ts new file mode 100644 index 0000000..efbb1be --- /dev/null +++ b/packages/core/src/payment-provider/stripe-provider.test.ts @@ -0,0 +1,88 @@ +// stripe-provider.test.ts — verifies the StripePaymentProvider adapter's +// pure logic (webhook parsing, refund status mapping, formatAmount) +// without any network calls. initCheckout/refund's actual HTTP path is +// exercised indirectly via ../stripe/*'s own existing behaviour (untouched +// by this fork) — this suite covers the NEW adapter code only. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { stripePaymentProvider } from "./stripe-provider"; + +test("parseWebhookEvent: checkout.session.completed maps to charge.succeeded with orderDraftId from metadata", () => { + const raw = JSON.stringify({ + type: "checkout.session.completed", + data: { + object: { + id: "cs_test_123", + object: "checkout.session", + mode: "payment", + amount_total: 250000, + currency: "kes", + metadata: { orderDraftId: "draft_abc" }, + client_reference_id: "draft_abc", + customer_details: { email: "fan@example.com", name: "Jane Fan", phone: "+254700000000" }, + shipping_details: { + address: { line1: "123 St", city: "Nairobi", country: "KE", postal_code: "00100" }, + }, + }, + }, + }); + const event = stripePaymentProvider.parseWebhookEvent(raw); + assert.equal(event.type, "charge.succeeded"); + if (event.type === "charge.succeeded") { + assert.equal(event.orderDraftId, "draft_abc"); + assert.equal(event.amount, 250000); + assert.equal(event.currency, "KES"); + assert.equal(event.customer.email, "fan@example.com"); + assert.equal(event.shippingAddress?.country, "KE"); + } +}); + +test("parseWebhookEvent: checkout.session.completed falls back to client_reference_id when metadata.orderDraftId absent", () => { + const raw = JSON.stringify({ + type: "checkout.session.completed", + data: { + object: { + id: "cs_test_456", + object: "checkout.session", + mode: "payment", + currency: "usd", + client_reference_id: "draft_xyz", + customer_details: { email: "a@b.com" }, + }, + }, + }); + const event = stripePaymentProvider.parseWebhookEvent(raw); + assert.equal(event.type, "charge.succeeded"); + if (event.type === "charge.succeeded") assert.equal(event.orderDraftId, "draft_xyz"); +}); + +test("parseWebhookEvent: payment_intent.payment_failed maps to charge.failed", () => { + const raw = JSON.stringify({ + type: "payment_intent.payment_failed", + data: { object: { id: "pi_test_1", metadata: { orderDraftId: "draft_fail" } } }, + }); + const event = stripePaymentProvider.parseWebhookEvent(raw); + assert.equal(event.type, "charge.failed"); + if (event.type === "charge.failed") assert.equal(event.orderDraftId, "draft_fail"); +}); + +test("parseWebhookEvent: unrelated event types map to 'unhandled', never dropped silently", () => { + const raw = JSON.stringify({ type: "customer.created", data: { object: {} } }); + const event = stripePaymentProvider.parseWebhookEvent(raw); + assert.equal(event.type, "unhandled"); + if (event.type === "unhandled") assert.equal(event.providerEventType, "customer.created"); +}); + +test("parseWebhookEvent: unparseable body maps to 'unhandled' rather than throwing", () => { + const event = stripePaymentProvider.parseWebhookEvent("not json{{{"); + assert.equal(event.type, "unhandled"); +}); + +test("parseWebhookEvent: checkout.session.completed with no correlatable id is 'unhandled' (never fabricates an orderDraftId)", () => { + const raw = JSON.stringify({ + type: "checkout.session.completed", + data: { object: { id: "cs_test_789", object: "checkout.session", mode: "payment" } }, + }); + const event = stripePaymentProvider.parseWebhookEvent(raw); + assert.equal(event.type, "unhandled"); +}); diff --git a/packages/core/src/payment-provider/stripe-provider.ts b/packages/core/src/payment-provider/stripe-provider.ts new file mode 100644 index 0000000..2e169b3 --- /dev/null +++ b/packages/core/src/payment-provider/stripe-provider.ts @@ -0,0 +1,201 @@ +/** + * StripePaymentProvider — Stripe implementing the PaymentProvider + * interface (see ./types.ts's module comment for why this fork exists). + * + * This is a thin adapter over the EXISTING `../stripe/*` modules — + * deliberately not a rewrite. Keeping `../stripe/*` untouched (beyond + * this wrapper) is what keeps the upstream diff minimal and PR-able: a + * maintainer reviewing "introduce PaymentProvider, refactor Stripe onto + * it" should see `stripe/*` unchanged and a new adapter file, not a + * reshuffle of working, already-tested code. + */ + +import type { PluginContext } from "emdash"; +import type { + CreateRefundInput, + InitCheckoutInput, + InitCheckoutResult, + NormalizedPaymentEvent, + PaymentProvider, + PaymentProviderCredentials, + PaymentProviderRuntimeContext, + RefundResult, + VerifyWebhookInput, + VerifyWebhookResult, +} from "./types"; +import { + createCheckoutSession, + type CheckoutLineItem, + type StripeCheckoutSession, +} from "../stripe/checkout-sessions"; +import { verifyStripeSignature } from "../stripe/webhook-verify"; +import { createRefund as stripeCreateRefund } from "../stripe/refunds"; +import type { StripeClientOptions } from "../stripe/client"; +import type { StripePaymentIntent } from "../stripe/payment-intents"; + +function toStripeClient(credentials: PaymentProviderCredentials): StripeClientOptions { + return { secretKey: credentials.secretKey }; +} + +/** Cast our narrow PaymentProviderRuntimeContext to the wider PluginContext + * the existing `../stripe/*` helpers expect. Safe: every stripe/* function + * only ever touches `ctx.http`, which our runtime context always provides — + * this cast exists purely to avoid rewriting the tested stripe/* call + * signatures for this adapter. */ +function asPluginContext(ctx: PaymentProviderRuntimeContext): PluginContext { + return ctx as unknown as PluginContext; +} + +export const stripePaymentProvider: PaymentProvider = { + id: "stripe", + label: "Stripe", + + supportedCurrencies(): string[] { + // Stripe supports 135+ currencies; Kenya isn't onboardable as a + // Stripe merchant account today (SPEC.md §3, "Stripe doesn't + // onboard Kenyan businesses") but Stripe can still charge in KES + // for a non-Kenya-domiciled Stripe account. We don't restrict the + // list here — the merchant's own Stripe account dictates what's + // actually chargeable, and Stripe's API rejects unsupported + // currencies at request time with a clear error. + return ["*"]; + }, + + async initCheckout( + ctx: PaymentProviderRuntimeContext, + input: InitCheckoutInput, + credentials: PaymentProviderCredentials, + ): Promise { + const client = toStripeClient(credentials); + const lineItems: CheckoutLineItem[] = input.lineItems.map((li) => ({ + amount: li.amount, + currency: li.currency.toLowerCase(), + name: li.name, + description: li.description, + quantity: li.quantity, + metadata: li.metadata, + })); + + const session: StripeCheckoutSession = await createCheckoutSession( + asPluginContext(ctx), + { + mode: "payment", + successUrl: input.successUrl, + cancelUrl: input.cancelUrl, + lineItems, + customerEmail: input.customer.email, + clientReferenceId: input.orderDraftId, + metadata: { orderDraftId: input.orderDraftId, ...input.metadata }, + paymentIntentMetadata: { orderDraftId: input.orderDraftId, ...input.metadata }, + paymentIntentReceiptEmail: input.customer.email, + }, + client, + `cs:${input.orderDraftId}`, + ); + + if (!session.url) { + throw new Error("Stripe did not return a hosted checkout URL"); + } + + return { providerReference: session.id, redirectUrl: session.url }; + }, + + async verifyWebhook(input: VerifyWebhookInput): Promise { + const result = await verifyStripeSignature({ + payload: input.rawBody, + signatureHeader: input.signatureHeader, + secret: input.secret, + }); + return { ok: result.ok, reason: result.reason }; + }, + + parseWebhookEvent(rawBody: string): NormalizedPaymentEvent { + let event: { type: string; data: { object: unknown } }; + try { + event = JSON.parse(rawBody); + } catch { + return { type: "unhandled", providerEventType: "unparseable", raw: rawBody }; + } + + if (event.type === "checkout.session.completed") { + const session = event.data.object as StripeCheckoutSession; + const orderDraftId = session.metadata?.orderDraftId ?? session.client_reference_id; + if (!orderDraftId) { + return { type: "unhandled", providerEventType: event.type, raw: event }; + } + return { + type: "charge.succeeded", + orderDraftId, + providerReference: session.id, + amount: session.amount_total ?? 0, + currency: (session.currency ?? "usd").toUpperCase(), + customer: { + email: session.customer_details?.email ?? "", + name: session.customer_details?.name, + phone: session.customer_details?.phone, + }, + shippingAddress: session.shipping_details?.address + ? { + line1: session.shipping_details.address.line1 ?? undefined, + line2: session.shipping_details.address.line2 ?? undefined, + city: session.shipping_details.address.city ?? undefined, + state: session.shipping_details.address.state ?? undefined, + postalCode: session.shipping_details.address.postal_code ?? undefined, + country: session.shipping_details.address.country ?? undefined, + } + : undefined, + channel: "Card", + raw: event, + }; + } + + if ( + event.type === "payment_intent.payment_failed" || + event.type === "payment_intent.canceled" + ) { + const pi = event.data.object as StripePaymentIntent; + const orderDraftId = pi.metadata?.orderDraftId; + if (!orderDraftId) { + return { type: "unhandled", providerEventType: event.type, raw: event }; + } + return { + type: "charge.failed", + orderDraftId, + providerReference: pi.id, + raw: event, + }; + } + + return { type: "unhandled", providerEventType: event.type, raw: event }; + }, + + async refund( + ctx: PaymentProviderRuntimeContext, + input: CreateRefundInput, + credentials: PaymentProviderCredentials, + ): Promise { + const client = toStripeClient(credentials); + const refund = await stripeCreateRefund( + asPluginContext(ctx), + { paymentIntent: input.providerReference, amount: input.amount }, + client, + `refund:${input.providerReference}:${input.amount ?? "full"}`, + ); + return { + providerRefundId: refund.id, + status: + refund.status === "succeeded" + ? "succeeded" + : refund.status === "failed" + ? "failed" + : "pending", + amount: refund.amount, + currency: refund.currency.toUpperCase(), + }; + }, + + formatAmount(money): string { + const major = (money.amount / 100).toFixed(2); + return `${money.currency.toUpperCase()} ${major}`; + }, +}; diff --git a/packages/core/src/payment-provider/types.ts b/packages/core/src/payment-provider/types.ts new file mode 100644 index 0000000..187888b --- /dev/null +++ b/packages/core/src/payment-provider/types.ts @@ -0,0 +1,232 @@ +/** + * PaymentProvider — the gateway abstraction this fork introduces. + * + * Upstream `emdashCommerce/dashcommerce` wires Stripe directly throughout + * `routes/checkout.ts` and `routes/webhook.ts` (see the module comments + * there) with no seam for a second gateway. This interface is that seam. + * + * Scope, deliberately: this interface covers the payment path every + * merchant needs regardless of gateway — initialise a charge (embedded or + * hosted), verify a webhook, mark a charge/session paid, refund. It does + * NOT attempt to abstract Stripe Connect (multi-vendor payouts) or Stripe + * Subscriptions/Billing (recurring invoices, dunning) — those are + * Stripe-specific product surfaces with no structural equivalent in + * Paystack (Paystack has "subaccounts" and its own recurring-charge + * primitive, but they are not a drop-in replacement, and Nondies RFC's + * memberships are annual one-off purchases with no auto-renew per + * SPEC.md §4.2/§7, so this fork does not need a Paystack subscription + * implementation to ship). A future contributor wanting Connect-equivalent + * marketplace payouts or Paystack recurring billing can extend this + * interface without breaking existing implementations — every method here + * is optional-safe to add to, none removed. + * + * Both `StripePaymentProvider` (this fork, refactored from upstream's + * direct Stripe calls) and `PaystackPaymentProvider` (in the sibling + * a gateway package (e.g. Paystack) package) implement this same interface. + * `routes/checkout.ts` and `routes/webhook.ts` are refactored to depend on + * `PaymentProvider` only, selected at runtime by `settings:paymentProvider` + * ("stripe" | "paystack"). + */ + +/** Integer minor units (cents, kobo) — never a float. */ +export interface Money { + amount: number; + currency: string; // ISO-4217, e.g. "KES", "USD" +} + +export interface PaymentProviderCustomer { + email: string; + name?: string; + phone?: string; +} + +export interface PaymentProviderAddress { + line1?: string; + line2?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; // ISO-3166 alpha-2 +} + +/** One line item for a hosted checkout page. */ +export interface PaymentProviderLineItem { + name: string; + description?: string; + amount: number; // minor units + currency: string; + quantity: number; + metadata?: Record; +} + +export interface InitCheckoutInput { + /** Our own order-draft id — always echoed back in metadata/reference so + * the webhook can correlate the provider's event to our cart snapshot. */ + orderDraftId: string; + amount: number; // minor units, total charge + currency: string; // ISO-4217 + customer: PaymentProviderCustomer; + lineItems: PaymentProviderLineItem[]; + successUrl: string; + cancelUrl: string; + metadata?: Record; + /** Channel hint for gateways that support payment-method restriction on + * the hosted page (Paystack: card/mobile_money/bank; ignored by Stripe). */ + preferredChannels?: string[]; +} + +export interface InitCheckoutResult { + /** Provider's own reference/session/transaction id. */ + providerReference: string; + /** URL to redirect the customer to for hosted payment. */ + redirectUrl: string; +} + +export interface VerifyWebhookInput { + /** Raw request body — signature verification must run against the + * exact bytes received, never a re-serialised parse. */ + rawBody: string; + /** Provider-specific signature header value(s), passed through + * verbatim (e.g. `Stripe-Signature`, `x-paystack-signature`). */ + signatureHeader: string; + /** Provider secret used for the HMAC (webhook secret, not the API + * secret key, when the provider distinguishes the two). */ + secret: string; +} + +export interface VerifyWebhookResult { + ok: boolean; + reason?: string; +} + +/** Normalised webhook event, after provider-specific verification and + * parsing — this is what `routes/webhook.ts` dispatches on, so it never + * needs to know which gateway sent it. */ +export type NormalizedPaymentEvent = + | { + type: "charge.succeeded"; + orderDraftId: string; + providerReference: string; + amount: number; + currency: string; + customer: PaymentProviderCustomer; + shippingAddress?: PaymentProviderAddress; + billingAddress?: PaymentProviderAddress; + /** Free-text label for how the customer paid — surfaced on the + * order (e.g. "M-Pesa", "Card", "Apple Pay"). */ + channel?: string; + raw: unknown; + } + | { + type: "charge.failed"; + orderDraftId: string; + providerReference: string; + reason?: string; + raw: unknown; + } + | { + type: "unhandled"; + providerEventType: string; + raw: unknown; + }; + +export interface CreateRefundInput { + /** Provider reference from the original successful charge. */ + providerReference: string; + /** Minor units. Omit for a full refund. */ + amount?: number; + currency: string; + reason?: string; +} + +export interface RefundResult { + providerRefundId: string; + status: "pending" | "succeeded" | "failed"; + amount: number; + currency: string; +} + +/** Provider-agnostic client credentials — each implementation defines its + * own concrete shape but every one is loaded the same way: read from + * plugin KV under `settings:SecretKey` / `settings:WebhookSecret`. */ +export interface PaymentProviderCredentials { + secretKey: string; + webhookSecret?: string; +} + +/** + * The seam. Both StripePaymentProvider and PaystackPaymentProvider + * implement this. `ctx` is always the plugin's `PluginContext` (or the + * subset of it — `http`, `log` — a provider needs); providers must use + * `ctx.http.fetch` (never global `fetch`) so `allowedHosts` is honoured in + * the sandbox, and `crypto.subtle` (never a Node `crypto` import) for any + * HMAC/signature work, matching the sandbox-safety rules already + * established in this fork's `stripe/*` modules. + */ +export interface PaymentProvider { + /** Machine-readable id, e.g. "stripe" | "paystack". Used for the + * `settings:paymentProvider` switch and for tagging orders with which + * gateway processed them. */ + readonly id: string; + + /** Human label for admin UI / receipts, e.g. "Stripe" | "Paystack". */ + readonly label: string; + + /** ISO-4217 currencies this provider can charge in this deployment. + * Stripe: broad. Paystack: KES + a handful of others depending on the + * merchant's Paystack business country — Nondies' Paystack account is + * Kenya-only, so this fork's Paystack provider returns `["KES"]`. */ + supportedCurrencies(): string[]; + + /** Initialise a hosted checkout/transaction. Returns a redirect URL — + * this interface deliberately does not model Stripe's embedded + * PaymentElement/client_secret flow, since Paystack (and most + * non-Stripe gateways) don't have an equivalent client-side primitive; + * hosted-redirect is the lowest common denominator every gateway + * supports, and it is also what "M-Pesa STK" checkout needs in + * practice (the STK push is triggered from Paystack's own hosted page + * once the customer picks the M-Pesa channel there). */ + initCheckout( + ctx: PaymentProviderRuntimeContext, + input: InitCheckoutInput, + credentials: PaymentProviderCredentials, + ): Promise; + + /** Verify a webhook's signature. Must be constant-time and must not + * assume any particular hash algorithm — Stripe uses HMAC-SHA256, + * Paystack uses HMAC-SHA512 (§4.2). */ + verifyWebhook(input: VerifyWebhookInput): Promise; + + /** Parse an already-signature-verified raw webhook body into a + * normalised event `routes/webhook.ts` can dispatch on without any + * gateway-specific branching. */ + parseWebhookEvent(rawBody: string): NormalizedPaymentEvent; + + /** Issue a refund (full or partial) against a previously successful + * charge. */ + refund( + ctx: PaymentProviderRuntimeContext, + input: CreateRefundInput, + credentials: PaymentProviderCredentials, + ): Promise; + + /** Format a minor-units amount for display in the provider's own + * convention (mostly relevant for KSh vs USD-style gateways that + * expect different minor-unit granularity; both Stripe and Paystack + * use 2-decimal minor units for KES/USD, but this hook exists so a + * future 0-decimal-currency provider doesn't need interface changes). */ + formatAmount(money: Money): string; +} + +/** The subset of PluginContext a PaymentProvider implementation may use. + * Kept narrow and named so implementations can't reach into unrelated + * plugin capabilities (storage, users, media) — payment providers only + * ever need outbound HTTP + logging. */ +export interface PaymentProviderRuntimeContext { + http: { fetch: typeof fetch }; + log: { + info: (msg: string, meta?: Record) => void; + warn: (msg: string, meta?: Record) => void; + error: (msg: string, meta?: Record) => void; + }; +} diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 981e20f..324d2d2 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ "src/sandbox-entry.ts", "src/admin/entry.tsx", "src/cli/merge-seed.ts", + "src/payment-provider.ts", // NOTE: src/astro/index.ts is NOT bundled — it imports `.astro` // components that rolldown can't compile. We ship it as source and // let the host's Astro build resolve the imports (see package.json