Skip to Content
FeaturesPayments

Payments

Hoikka uses a provider pattern for payments. Stripe is integrated with real Stripe Elements for secure card collection.

Payment Flow

Stripe

1. Customer reaches checkout 2. Select Stripe as payment method 3. Create payment → Stripe PaymentIntent created 4. Stripe Elements renders card form on frontend 5. Client-side confirmation via stripe.confirmPayment() 6. Server confirms payment status 7. Order is completed → redirect to thank-you

Mock (development)

1. Customer reaches checkout 2. Select mock payment method 3. Click "Place Order" → payment created and order completed in one step 4. Redirect to thank-you

Stripe Integration

Environment Variables

STRIPE_SECRET_KEY="sk_test_..." PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."

Server-Side (PaymentIntent)

const { payment, paymentInfo } = await paymentService.createPayment(order, stripeMethodId); // paymentInfo.clientSecret → used by Stripe Elements // paymentInfo.providerTransactionId → Stripe PaymentIntent ID

Frontend (Stripe Elements)

The StripePayment component handles loading Stripe.js, mounting the Payment Element, and exposing refs for confirmation:

<script lang="ts"> import StripePayment from "$lib/components/storefront/StripePayment.svelte"; let stripeRef, elementsRef; </script> {#if paymentInfo?.methodCode === "stripe" && paymentInfo?.clientSecret} <StripePayment clientSecret={paymentInfo.clientSecret} onready={(stripe, elements) => { stripeRef = stripe; elementsRef = elements; }} /> {/if}

On form submission, confirm the payment client-side before the server action:

const { error } = await stripeRef.confirmPayment({ elements: elementsRef, redirect: "if_required" });

Server Confirmation

There is no Stripe webhook. After the client-side confirmPayment, completeOrder (src/lib/remote/checkout.remote.ts) transitions the draft to payment_pending and checks the PaymentIntent status via the provider:

// paymentId is the internal payments row id, not the Stripe transaction id const paymentStatus = await paymentService.confirmPayment(payment.id); if (isPaymentSuccessful(paymentStatus)) { await orderService.transitionState(order.id, "paid"); }

The generic /api/webhooks/[provider] endpoint is an HMAC-signed receiver for integrations (ERP sync etc.) that records events to the outbox; it is not part of the payment flow.

Payment States

StateDescription
pendingPayment created, awaiting completion
authorizedPayment authorized, not captured
settledPayment captured successfully
declinedPayment failed
refundedPayment refunded

Refunds

Only settled payments can be refunded:

// Full refund await paymentService.refundPayment(paymentId); // Partial refund (amount in cents) await paymentService.refundPayment(paymentId, 1500);

Adding Payment Providers

Implement the PaymentProvider interface:

import type { PaymentProvider } from "$lib/server/services/payments/types"; class MyProvider implements PaymentProvider { code = "my-provider"; async createPayment(order) { // Create payment with external provider } async confirmPayment(transactionId) { // Check payment status } async refundPayment(transactionId, amount?) { // Process refund } }

Register in src/lib/server/services/payments/index.ts:

const PROVIDERS: Map<string, PaymentProvider> = new Map([ ["mock", new MockProvider()], ["stripe", new StripeProvider()], ["my-provider", new MyProvider()] ]);

Or at runtime: paymentService.registerProvider(new MyProvider()). confirmPayment and refundPayment are optional on the interface; providers without them fall back to the stored state / throw on refund.

Last updated on