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-youMock (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-youStripe 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 IDFrontend (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
| State | Description |
|---|---|
pending | Payment created, awaiting completion |
authorized | Payment authorized, not captured |
settled | Payment captured successfully |
declined | Payment failed |
refunded | Payment 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.