svelte-payments

@nomideusz/svelte-payments v0.9.0

Provider-agnostic payments for Svelte 5 booking products — hosted checkout, embedded card fields, subscriptions, refunds, and a webhook-safe settlement state machine. Currently ships a Mollie adapter (zero dependencies, plain fetch); the interfaces are deliberately shaped to fit Stripe Checkout next.

On this page

Installation#

npm install @nomideusz/svelte-payments

The model#

Amounts are always integer minor units (cents/grosze). The provider's webhook body is never trusted — the authenticated re-fetch of the payment is the verification.

import { mollie, createCheckout } from '@nomideusz/svelte-payments/server';

const provider = mollie(MOLLIE_API_KEY);

const checkout = createCheckout(provider, {
  // your only integration point — all callbacks must be idempotent
  savePayment: async (p) => db.upsertPayment(p),
  onPaid: async (p) => confirmOrder(p.metadata?.orderId),
  onFailed: async (p) => releaseHold(p.metadata?.orderId),
});

// 1. start — create the payment, redirect the customer to checkoutUrl
const payment = await checkout.start({
  amount: 4900, currency: 'PLN',
  description: 'Morning Vinyasa — booking YG-ABC12345',
  redirectUrl: `${origin}/confirmed?payment=done`,
  webhookUrl: `${origin}/api/webhooks/mollie`,
  metadata: { orderId },
});

// 2. settle — from the webhook AND the customer's return trip (same function)
export const POST = ({ request }) => checkout.handleWebhook(request);
await checkout.settle(paymentId); // e.g. on the confirmation page

handleWebhook answers 400 for junk and forged ids (stop retrying) and rethrows real failures so your route 500s and the provider retries.

SvelteKit caveat: a plain +server.ts POST route is NOT enough on its own. Mollie posts application/x-www-form-urlencoded with no Origin header, and SvelteKit's built-in CSRF protection 403s exactly that shape — and it runs before any handle hook, so neither a hook nor csrf.trustedOrigins can exempt the route (trustedOrigins matches Origin values; an Origin-less request never passes it). The pattern that works in production: disable the built-in check in svelte.config.js (csrf: { checkOrigin: false }, or trustedOrigins: ['*'] on newer Kit), then in hooks.server.ts handle the webhook path first and re-implement the origin check yourself for every other mutating request — otherwise every webhook delivery fails and Mollie keeps retrying.

Subscriptions#

Customers, mandates and recurring charges (Mollie: card mandates via a sequenceType: 'first' payment):

const customer = await provider.createCustomer({ email });
await provider.createPayment({ ...input, customerId: customer.id, sequenceType: 'first' });
// once paid, the mandate exists:
await provider.createSubscription({
  customerId: customer.id, amount: 4900, currency: 'PLN',
  interval: '1 month', description: 'Premium', startDate: nextMonth,
});

Refunds#

await provider.refund(paymentId, { amount: 4900, currency: 'PLN' });

Ledger (@nomideusz/svelte-payments/ledger)#

A pure financial-event-ledger subpath — no DB, no SDK, no env. It owns the event vocabulary, the idempotency-key grammar, and the one safe ordering for a keyed money movement; your app owns the table schema, the SDK calls, and the duplicate-recovery policy.

import { executeKeyedOperation, refundKey, derivePaymentStatus } from '@nomideusz/svelte-payments/ledger';

// refundKey encodes the CUMULATIVE amount, so a retry reuses the key while a
// genuine second refund gets a new one.
const res = await executeKeyedOperation(
  store, // your LedgerStore: insert(event) atomic on eventKey, markCompleted(key)
  { eventKey: refundKey(bookingId, refundedSoFar + amount), type: 'refund', subjectId: bookingId, paymentId, amount, currency: 'PLN' },
  (idempotencyKey) => provider.refund(paymentId, { amount, currency: 'PLN', idempotencyKey }),
);
if (res.status === 'executed') { /* update your totals */ }

derivePaymentStatus(totalMinor, refundedMinor); // 'paid' | 'partially_refunded' | 'refunded'

executeKeyedOperation appends the event row → calls the provider with the key → marks it completed. A duplicate key short-circuits without touching the provider; a provider failure rethrows and leaves the row for your recovery path. Amounts are validated as integer minor units. The vocabulary is provider-neutral (charge / refund / chargeback); marketplace shapes (transfers, payouts) are deliberately excluded from the core.

Embedded card fields#

MollieCard wraps Mollie Components (PCI SAQ-A hosted iframes) as a Svelte 5 component, themeable via --asini-* custom properties:

<script>
  import { MollieCard } from '@nomideusz/svelte-payments';
  let card;
</script>

<MollieCard bind:this={card} profileId="pfl_..." testmode locale="pl_PL" />
<button onclick={async () => submit(await card.createToken())}>Pay</button>

Pass the token as cardToken to createPayment — the customer skips the provider's method-selection page entirely (only 3-D Secure may still redirect).

License#

MIT