# Payment Layer

# QODY Payment Layer

**Author:** Finverge (Markos Zachariadis) | **Date:** 2026-06-22

## Payment Provider Strategy per Market

### Bosnia &amp; Herzegovina / Balkans (Primary Market)

<table id="bkmrk-provider-use-case-co"><thead><tr><th>Provider</th><th>Use Case</th><th>Coverage</th><th>Integration Complexity</th></tr></thead><tbody><tr><td>**Stripe**</td><td>Card payments (Visa/Mastercard)</td><td>Global, BiH-supported</td><td>Low (REST API, Kotlin SDK)</td></tr><tr><td>**MonriPay**</td><td>Local Balkan PSP</td><td>Regional card acquiring</td><td>Medium (API docs available)</td></tr><tr><td>**Corvus Pay**</td><td>Regional card processor</td><td>Croatia + BiH</td><td>Medium (REST API)</td></tr></tbody></table>

**Recommendation:**

1. **Start with Stripe** — best developer experience, supports BiH merchants (USD/EUR settlement), card tokenization, PCI-compliant
2. **Add Monri** as Phase 2 — local brand recognition, BAM settlement option, lower interchange for Balkan cards

### Norway (Secondary Market)

<table id="bkmrk-provider-use-case-co-1"><thead><tr><th>Provider</th><th>Use Case</th><th>Coverage</th><th>Integration Complexity</th></tr></thead><tbody><tr><td>**Vipps MobilePay**</td><td>Dominant Norwegian wallet</td><td>Norway only</td><td>Medium (OAuth, polling)</td></tr><tr><td>**Stripe**</td><td>Card payments + Apple Pay</td><td>Global</td><td>Low</td></tr></tbody></table>

**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:

```kotlin
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:**

1. Guest adds items to cart
2. Guest taps "Pay Now"
3. Backend creates `PaymentIntent` (provider-agnostic)
4. Frontend redirects to payment provider (Stripe Checkout, Vipps landing page, or Monri hosted form)
5. Provider webhooks `payment.succeeded` → backend confirms order → notifies kitchen

### Pay-at-End (Open Tab)

**Flow:**

1. Guest orders multiple rounds (drinks, appetizers, mains)
2. Each order appends to the same `session_id` (table session)
3. When guest requests bill, backend aggregates all unpaid orders for that session
4. Guest sees total → pays once

### Split Bill

**Three Modes:**

<table id="bkmrk-mode-description-bac"><thead><tr><th>Mode</th><th>Description</th><th>Backend Logic</th></tr></thead><tbody><tr><td>**By Item**</td><td>Guest A pays for items 1, 3; Guest B pays for items 2, 4</td><td>Create separate orders per guest</td></tr><tr><td>**Evenly**</td><td>Total divided by N guests</td><td>Single order, N payment intents of `total / N`</td></tr><tr><td>**By Amount**</td><td>Guest A pays 30 BAM, Guest B pays 20 BAM</td><td>Validate `sum(amounts) == order_total`</td></tr></tbody></table>

### Tipping

**Implementation:**

1. After payment intent created, frontend shows tip options (10%, 15%, 20%, custom)
2. Tip is added to `payment.amount` before provider confirmation
3. 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).

```kotlin
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

<table id="bkmrk-market-category-rate"><thead><tr><th>Market</th><th>Category</th><th>Rate</th></tr></thead><tbody><tr><td>**Bosnia &amp; Herzegovina**</td><td>All items (food, alcohol, general)</td><td>17%</td></tr><tr><td>**Norway**</td><td>Food</td><td>15%</td></tr><tr><td>**Norway**</td><td>Alcohol</td><td>25%</td></tr><tr><td>**Norway**</td><td>General</td><td>25%</td></tr></tbody></table>

```kotlin
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 &amp; 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:**

1. Batch job runs nightly (cron or Ktor scheduled task)
2. For each venue, query all `payments.status = 'succeeded'` from yesterday
3. Compare with provider settlement reports (Stripe Payouts API, Vipps reports)
4. Flag discrepancies (missing payments, refunds not recorded)

## Settlement &amp; Payouts to Venues

### Marketplace Model vs Venue-Direct PSP

<table id="bkmrk-model-description-pr"><thead><tr><th>Model</th><th>Description</th><th>Pros</th><th>Cons</th></tr></thead><tbody><tr><td>**Marketplace (Stripe Connect)**</td><td>QODY holds master Stripe account; venues are Connected Accounts</td><td>Centralized control, auto platform fee</td><td>QODY responsible for payouts, regulatory complexity</td></tr><tr><td>**Venue-Direct PSP**</td><td>Each venue has own Stripe/Vipps account</td><td>No payment license needed, venue owns relationship</td><td>Cannot auto-deduct SaaS fees</td></tr></tbody></table>

**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)

```kotlin
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 &amp; 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 &amp; 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)

```kotlin
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.

```kotlin
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:**

```sql
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

<table id="bkmrk-feature-unleash-flag"><thead><tr><th>Feature</th><th>Unleash Flag</th><th>Default</th><th>Gating Reason</th></tr></thead><tbody><tr><td>**Split Bill**</td><td>`qody.payment.split_bill`</td><td>OFF</td><td>Premium plan only</td></tr><tr><td>**Tipping**</td><td>`qody.payment.tipping`</td><td>ON (BiH), OFF (NO)</td><td>Cultural preference</td></tr><tr><td>**Partial Payments**</td><td>`qody.payment.partial_payments`</td><td>OFF</td><td>Premium plan only</td></tr><tr><td>**Service Charge**</td><td>`qody.payment.service_charge`</td><td>OFF</td><td>Per-venue opt-in</td></tr></tbody></table>

## 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

1. **Stripe-first** for BiH/Balkans (card), Vipps for Norway (wallet), Monri as Phase 2 local option
2. **Provider abstraction layer** (`PaymentGateway` interface) to avoid lock-in
3. **Marketplace model (Stripe Connect)** for Phase 1 — QODY takes 3-5% platform fee, venues auto-paid out
4. **Money in minor units** (Int, never Float) — strict double-entry discipline
5. **Split bill, tipping, partial payments** — all gated by Unleash flags (plan-tier and market-specific)
6. **Non-fiscal receipts Phase 1** — add ESET/CPF when regulatory clarity achieved
7. **Idempotent webhook handling** — `(provider, provider_payment_id)` unique constraint
8. **Reconciliation nightly** — compare QODY ledger vs provider settlement reports