Payment Layer QODY Payment Layer Author: Finverge (Markos Zachariadis) | Date: 2026-06-22 Payment Provider Strategy per Market Bosnia & Herzegovina / Balkans (Primary Market) Provider Use Case Coverage Integration Complexity Stripe Card payments (Visa/Mastercard) Global, BiH-supported Low (REST API, Kotlin SDK) MonriPay Local Balkan PSP Regional card acquiring Medium (API docs available) Corvus Pay Regional card processor Croatia + BiH Medium (REST API) Recommendation: Start with Stripe — best developer experience, supports BiH merchants (USD/EUR settlement), card tokenization, PCI-compliant Add Monri as Phase 2 — local brand recognition, BAM settlement option, lower interchange for Balkan cards Norway (Secondary Market) Provider Use Case Coverage Integration Complexity Vipps MobilePay Dominant Norwegian wallet Norway only Medium (OAuth, polling) Stripe Card payments + Apple Pay Global Low Recommendation: Vipps MobilePay (90%+ Norwegian adoption) + Stripe as fallback for international cards. Provider Abstraction Layer CRITICAL: QODY must NOT be locked into one provider. Payment Gateway Abstraction pattern: interface PaymentGateway { suspend fun createPaymentIntent(request: PaymentIntentRequest): PaymentIntentResponse suspend fun confirmPayment(intentId: String): PaymentConfirmationResponse suspend fun refund(paymentId: String, amount: Money): RefundResponse suspend fun handleWebhook(payload: String, signature: String): WebhookEvent } // Implementations: class StripeGateway : PaymentGateway { /* Stripe-specific */ } class VippsGateway : PaymentGateway { /* Vipps-specific */ } class MonriGateway : PaymentGateway { /* Monri-specific */ } // Factory for per-venue routing: class PaymentGatewayFactory(private val config: PaymentConfig) { fun forVenue(venueId: UUID): PaymentGateway { return when (config.getProviderForVenue(venueId)) { PaymentProvider.STRIPE -> StripeGateway(config.stripe) PaymentProvider.VIPPS -> VippsGateway(config.vipps) PaymentProvider.MONRI -> MonriGateway(config.monri) } } } Checkout Flows Pay-Now (Per Order) Flow: Guest adds items to cart Guest taps "Pay Now" Backend creates PaymentIntent (provider-agnostic) Frontend redirects to payment provider (Stripe Checkout, Vipps landing page, or Monri hosted form) Provider webhooks payment.succeeded → backend confirms order → notifies kitchen Pay-at-End (Open Tab) Flow: Guest orders multiple rounds (drinks, appetizers, mains) Each order appends to the same session_id (table session) When guest requests bill, backend aggregates all unpaid orders for that session Guest sees total → pays once Split Bill Three Modes: Mode Description Backend Logic By Item Guest A pays for items 1, 3; Guest B pays for items 2, 4 Create separate orders per guest Evenly Total divided by N guests Single order, N payment intents of total / N By Amount Guest A pays 30 BAM, Guest B pays 20 BAM Validate sum(amounts) == order_total Tipping Implementation: After payment intent created, frontend shows tip options (10%, 15%, 20%, custom) Tip is added to payment.amount before provider confirmation Backend splits tip revenue in settlement Feature Flag: Tipping may be disabled for some markets. Use Unleash flag qody.tipping.enabled (venue-level). Money Model Amount Storage RULE: Always store monetary amounts in minor units (cents, øre, feninga). data class Money( val amountMinor: Int, // e.g., 1250 = 12.50 BAM val currency: Currency ) { val amountMajor: BigDecimal get() = BigDecimal(amountMinor).divide(BigDecimal(100), 2, RoundingMode.HALF_UP) } enum class Currency(val code: String, val symbol: String, val minorUnits: Int) { BAM("BAM", "KM", 2), NOK("NOK", "kr", 2), EUR("EUR", "€", 2) } Tax / VAT Calculation Market Category Rate Bosnia & Herzegovina All items (food, alcohol, general) 17% Norway Food 15% Norway Alcohol 25% Norway General 25% val TAX_RULES = mapOf( "BA" to mapOf( "food" to BigDecimal("0.17"), "alcohol" to BigDecimal("0.17"), "general" to BigDecimal("0.17") ), "NO" to mapOf( "food" to BigDecimal("0.15"), "alcohol" to BigDecimal("0.25"), "general" to BigDecimal("0.25") ) ) fun calculateTax(item: MenuItem, quantity: Int, country: String): Int { val rate = TAX_RULES[country]?.get(item.taxCategory) ?: BigDecimal("0.25") val subtotal = item.priceMinor * quantity return (subtotal.toBigDecimal() * rate).toInt() } Currency & Rounding Multi-Currency Note: QODY must support BAM (BiH), NOK (Norway), EUR (potential expansion). Venue sets its default currency in venues.default_currency . Prices in menu_items.price_minor are always in that venue's currency. Reconciliation Daily Reconciliation Flow: Batch job runs nightly (cron or Ktor scheduled task) For each venue, query all payments.status = 'succeeded' from yesterday Compare with provider settlement reports (Stripe Payouts API, Vipps reports) Flag discrepancies (missing payments, refunds not recorded) Settlement & Payouts to Venues Marketplace Model vs Venue-Direct PSP Model Description Pros Cons Marketplace (Stripe Connect) QODY holds master Stripe account; venues are Connected Accounts Centralized control, auto platform fee QODY responsible for payouts, regulatory complexity Venue-Direct PSP Each venue has own Stripe/Vipps account No payment license needed, venue owns relationship Cannot auto-deduct SaaS fees Recommendation: Phase 1 (MVP): Marketplace model (Stripe Connect) — simpler for pilot venues, faster onboarding Phase 2: Offer venue-direct option for large chains with existing PSP contracts Stripe Connect Implementation (Marketplace Model) val paymentIntent = stripe.paymentIntents.create( PaymentIntentCreateParams.builder() .setAmount(order.totalMinor.toLong()) .setCurrency(order.currency.code.lowercase()) .setApplicationFeeAmount((order.totalMinor * 0.05).toLong()) // 5% QODY fee .setTransferData( PaymentIntentCreateParams.TransferData.builder() .setDestination(venue.stripeConnectedAccountId) .build() ) .build() ) Payout Cadence: Stripe automatically pays out to venue bank account (default: daily for Standard accounts, weekly for Express). Fiscalization / Receipts Bosnia & Herzegovina Fiscal Device Requirement: Cash sales require ESET fiscal devices . Card/online payments: Current regulation unclear whether ESET required for cashless-only venues. QODY Implementation: Phase 1: Generate PDF receipt (not fiscalized). Mark as "Proforma" or "Non-Fiscal Receipt" Phase 2: Integrate CPF API for B2B invoices (when CPF specs published) ESET Integration: Requires hardware device. Send order data to ESET device via REST API (if device supports) Recommendation: Launch QODY in BiH with non-fiscal receipts (PDF) for pilot phase. Add ESET integration when regulatory clarity is confirmed. Norway Fiscal Requirement: Norway requires sales records for VAT reporting, but no real-time fiscal device. Receipts must include: Venue name, address, org.nr Date, time Itemized list with VAT breakdown Payment method Receipt number (sequential or unique) QODY Implementation: Generate receipt with VAT breakdown (25% vs 15% for food). Store receipt PDF in cloud storage. Email receipt to guest (optional). Webhooks & Idempotency Webhook Handling Providers send webhooks for: payment.succeeded (confirm order, notify kitchen) payment.failed (mark order as failed, notify guest) refund.created (update order status to refunded) post("/webhooks/stripe") { val payload = call.receiveText() val signature = call.request.header("Stripe-Signature") ?: throw BadRequestException("Missing signature") val event = stripeGateway.handleWebhook(payload, signature) when (event.type) { "payment_intent.succeeded" -> { val paymentIntentId = event.data["id"] as String paymentService.confirmPayment(paymentIntentId) } "payment_intent.payment_failed" -> { val paymentIntentId = event.data["id"] as String paymentService.markFailed(paymentIntentId) } } call.respond(HttpStatusCode.OK) } Security: Verify webhook signature (Stripe uses HMAC SHA256, Vipps uses HMAC SHA512). Store webhook secret in environment variable. Idempotency RULE: Payment confirmations must be idempotent. A webhook may arrive multiple times. suspend fun confirmPayment(paymentIntentId: String) { val payment = paymentRepository.findByProviderPaymentId(paymentIntentId) ?: throw NotFoundException("Payment not found") if (payment.status == PaymentStatus.SUCCEEDED) { // Already processed; idempotent return return } transaction { paymentRepository.updateStatus(payment.id, PaymentStatus.SUCCEEDED, Instant.now()) orderRepository.updateTotalPaid(payment.orderId, payment.amountMinor) // Notify kitchen, send receipt, etc. } } Database Constraint: CREATE UNIQUE INDEX idx_payments_provider_id ON payments(provider, provider_payment_id); This ensures (provider, provider_payment_id) is unique → prevents duplicate payment records. Feature-Flag Gating Feature Unleash Flag Default Gating Reason Split Bill qody.payment.split_bill OFF Premium plan only Tipping qody.payment.tipping ON (BiH), OFF (NO) Cultural preference Partial Payments qody.payment.partial_payments OFF Premium plan only Service Charge qody.payment.service_charge OFF Per-venue opt-in Implementation Roadmap Phase 1 (MVP — 4-6 weeks) Stripe integration (card payments) Pay-now per order Pay-at-end (open tab) Basic receipt generation (PDF, non-fiscal) Marketplace model (Stripe Connect) Payment webhook handling + idempotency Unleash feature flags for tipping, split bill Phase 2 (Expansion — 8-10 weeks) Split bill (by item, evenly, by amount) Tipping with configurable rates Vipps integration (Norway) Monri integration (BiH) Partial payments ESET fiscal device integration (BiH) Reconciliation reports Phase 3 (Advanced — 12+ weeks) Venue-direct PSP option Multi-currency support (BAM, NOK, EUR) CPF e-invoice integration (BiH B2B) Refund self-service for venues Payment analytics dashboard Summary — Key Decisions Stripe-first for BiH/Balkans (card), Vipps for Norway (wallet), Monri as Phase 2 local option Provider abstraction layer ( PaymentGateway interface) to avoid lock-in Marketplace model (Stripe Connect) for Phase 1 — QODY takes 3-5% platform fee, venues auto-paid out Money in minor units (Int, never Float) — strict double-entry discipline Split bill, tipping, partial payments — all gated by Unleash flags (plan-tier and market-specific) Non-fiscal receipts Phase 1 — add ESET/CPF when regulatory clarity achieved Idempotent webhook handling — (provider, provider_payment_id) unique constraint Reconciliation nightly — compare QODY ledger vs provider settlement reports