diff --git a/services/payments/src/integrationTest/kotlin/com/fincore/payments/infrastructure/persistence/PaymentsSchemaPersistenceIT.kt b/services/payments/src/integrationTest/kotlin/com/fincore/payments/infrastructure/persistence/PaymentsSchemaPersistenceIT.kt new file mode 100644 index 0000000..2b29284 --- /dev/null +++ b/services/payments/src/integrationTest/kotlin/com/fincore/payments/infrastructure/persistence/PaymentsSchemaPersistenceIT.kt @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.infrastructure.persistence + +import com.fincore.events.OutboxStatus +import com.fincore.payments.domain.enum.PaymentStatus +import com.fincore.test.containers.PostgresContainerExtension +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal +import java.time.Instant +import java.util.UUID + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +@ExtendWith(PostgresContainerExtension::class) +class PaymentsSchemaPersistenceIT( + @Autowired private val payments: PaymentRepository, + @Autowired private val paymentEvents: PaymentEventRepository, + @Autowired private val webhooks: ProcessedWebhookRepository, + @Autowired private val outbox: PaymentOutboxEventRepository, +) { + @AfterEach + fun cleanUp() { + paymentEvents.deleteAll() + webhooks.deleteAll() + outbox.deleteAll() + payments.deleteAll() + } + + @Test + fun `should round-trip a payment preserving amount precision status and version`() { + val id = UUID.randomUUID() + val amount = BigDecimal("100.123456789012345678") + payments.saveAndFlush( + PaymentEntity(id, "order-1", amount, "USD", PaymentStatus.INITIATED, Instant.now(), 0L), + ) + + val reloaded = payments.findById(id).orElseThrow() + reloaded.reference shouldBe "order-1" + reloaded.amount.compareTo(amount) shouldBe 0 + reloaded.currency.trim() shouldBe "USD" + reloaded.status shouldBe PaymentStatus.INITIATED + reloaded.version shouldBe 0L + } + + @Test + fun `should round-trip a payment event with a json payload`() { + val id = UUID.randomUUID() + paymentEvents.saveAndFlush( + PaymentEventEntity(id, UUID.randomUUID(), "com.fincore.payment.initiated.v1", "{\"k\":\"v\"}", Instant.now()), + ) + + paymentEvents.findById(id).orElseThrow().eventType shouldBe "com.fincore.payment.initiated.v1" + } + + @Test + fun `should round-trip an outbox row with pending status and no lease`() { + val id = UUID.randomUUID() + outbox.saveAndFlush( + PaymentOutboxEventEntity( + id, + "Payment", + "pay_1", + "com.fincore.payment.initiated.v1", + "{}", + OutboxStatus.PENDING, + Instant.now(), + null, + 0, + null, + null, + ), + ) + + val reloaded = outbox.findById(id).orElseThrow() + reloaded.status shouldBe OutboxStatus.PENDING + reloaded.attempts shouldBe 0 + reloaded.leasedAt.shouldBeNull() + } + + @Test + fun `should keep a single row per delivery id`() { + webhooks.saveAndFlush(ProcessedWebhookEntity("delivery-1", Instant.now())) + webhooks.saveAndFlush(ProcessedWebhookEntity("delivery-1", Instant.now())) + + webhooks.count() shouldBe 1L + } + + companion object { + @JvmStatic + @DynamicPropertySource + fun datasourceProperties(registry: DynamicPropertyRegistry) { + registry.add("spring.datasource.url") { PostgresContainerExtension.jdbcUrl } + registry.add("spring.datasource.username") { PostgresContainerExtension.username } + registry.add("spring.datasource.password") { PostgresContainerExtension.password } + registry.add("spring.jpa.hibernate.ddl-auto") { "none" } + } + } +} diff --git a/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentEntity.kt b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentEntity.kt new file mode 100644 index 0000000..3f16241 --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentEntity.kt @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.infrastructure.persistence + +import com.fincore.payments.domain.enum.PaymentStatus +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.persistence.Version +import java.math.BigDecimal +import java.time.Instant +import java.util.UUID + +@Entity +@Table(name = "payments", schema = "payments") +@Suppress("LongParameterList") +class PaymentEntity( + @Id + @Column(name = "id", nullable = false, updatable = false) + var id: UUID, + @Column(name = "reference", nullable = false, updatable = false) + var reference: String, + @Column(name = "amount", nullable = false, updatable = false) + var amount: BigDecimal, + @Column(name = "currency", nullable = false, updatable = false) + var currency: String, + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: PaymentStatus, + @Column(name = "created_at", nullable = false, updatable = false) + var createdAt: Instant, + @Version + @Column(name = "version", nullable = false) + var version: Long, +) diff --git a/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentEventEntity.kt b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentEventEntity.kt new file mode 100644 index 0000000..2795f9f --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentEventEntity.kt @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.infrastructure.persistence + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.hibernate.annotations.Immutable +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.type.SqlTypes +import java.time.Instant +import java.util.UUID + +@Entity +@Immutable +@Table(name = "payment_events", schema = "payments") +class PaymentEventEntity( + @Id + @Column(name = "id", nullable = false, updatable = false) + var id: UUID, + @Column(name = "payment_id", nullable = false, updatable = false) + var paymentId: UUID, + @Column(name = "event_type", nullable = false, updatable = false) + var eventType: String, + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "payload", nullable = false, updatable = false) + var payload: String, + @Column(name = "created_at", nullable = false, updatable = false) + var createdAt: Instant, +) diff --git a/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentEventRepository.kt b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentEventRepository.kt new file mode 100644 index 0000000..ae0cdc2 --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentEventRepository.kt @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.infrastructure.persistence + +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface PaymentEventRepository : JpaRepository diff --git a/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentOutboxEventEntity.kt b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentOutboxEventEntity.kt new file mode 100644 index 0000000..c989897 --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentOutboxEventEntity.kt @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.infrastructure.persistence + +import com.fincore.events.OutboxStatus +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.type.SqlTypes +import java.time.Instant +import java.util.UUID + +@Entity +@Table(name = "outbox_events", schema = "payments") +@Suppress("LongParameterList") +class PaymentOutboxEventEntity( + @Id + @Column(name = "id", nullable = false, updatable = false) + var id: UUID, + @Column(name = "aggregate_type", nullable = false, updatable = false) + var aggregateType: String, + @Column(name = "aggregate_id", nullable = false, updatable = false) + var aggregateId: String, + @Column(name = "event_type", nullable = false, updatable = false) + var eventType: String, + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "payload", nullable = false, updatable = false) + var payload: String, + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: OutboxStatus, + @Column(name = "created_at", nullable = false, updatable = false) + var createdAt: Instant, + @Column(name = "published_at") + var publishedAt: Instant?, + @Column(name = "attempts", nullable = false) + var attempts: Int, + @Column(name = "last_error") + var lastError: String?, + @Column(name = "leased_at") + var leasedAt: Instant? = null, +) diff --git a/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentOutboxEventRepository.kt b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentOutboxEventRepository.kt new file mode 100644 index 0000000..8acf33f --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentOutboxEventRepository.kt @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.infrastructure.persistence + +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface PaymentOutboxEventRepository : JpaRepository diff --git a/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentRepository.kt b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentRepository.kt new file mode 100644 index 0000000..f845b1e --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/PaymentRepository.kt @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.infrastructure.persistence + +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface PaymentRepository : JpaRepository diff --git a/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/ProcessedWebhookEntity.kt b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/ProcessedWebhookEntity.kt new file mode 100644 index 0000000..f29591b --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/ProcessedWebhookEntity.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.infrastructure.persistence + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.hibernate.annotations.Immutable +import java.time.Instant + +@Entity +@Immutable +@Table(name = "processed_webhooks", schema = "payments") +class ProcessedWebhookEntity( + @Id + @Column(name = "delivery_id", nullable = false, updatable = false) + var deliveryId: String, + @Column(name = "received_at", nullable = false, updatable = false) + var receivedAt: Instant, +) diff --git a/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/ProcessedWebhookRepository.kt b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/ProcessedWebhookRepository.kt new file mode 100644 index 0000000..5411d6b --- /dev/null +++ b/services/payments/src/main/kotlin/com/fincore/payments/infrastructure/persistence/ProcessedWebhookRepository.kt @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.payments.infrastructure.persistence + +import org.springframework.data.jpa.repository.JpaRepository + +interface ProcessedWebhookRepository : JpaRepository diff --git a/services/payments/src/main/resources/application.yml b/services/payments/src/main/resources/application.yml index 412274a..0148cac 100644 --- a/services/payments/src/main/resources/application.yml +++ b/services/payments/src/main/resources/application.yml @@ -1,3 +1,8 @@ spring: application: name: payments + jpa: + hibernate: + ddl-auto: none + liquibase: + change-log: classpath:db/changelog/db.changelog-master.yaml diff --git a/services/payments/src/main/resources/db/changelog/db.changelog-master.yaml b/services/payments/src/main/resources/db/changelog/db.changelog-master.yaml new file mode 100644 index 0000000..a2a055f --- /dev/null +++ b/services/payments/src/main/resources/db/changelog/db.changelog-master.yaml @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: BUSL-1.1 +# SPDX-FileCopyrightText: 2026 FinCore Engine Authors +databaseChangeLog: + - include: + file: v0.1/001-schema.sql + relativeToChangelogFile: true + - include: + file: v0.1/010-payments.sql + relativeToChangelogFile: true + - include: + file: v0.1/011-payment-events.sql + relativeToChangelogFile: true + - include: + file: v0.1/012-processed-webhooks.sql + relativeToChangelogFile: true + - include: + file: v0.1/013-outbox-events.sql + relativeToChangelogFile: true diff --git a/services/payments/src/main/resources/db/changelog/v0.1/001-schema.sql b/services/payments/src/main/resources/db/changelog/v0.1/001-schema.sql new file mode 100644 index 0000000..c0e1501 --- /dev/null +++ b/services/payments/src/main/resources/db/changelog/v0.1/001-schema.sql @@ -0,0 +1,6 @@ +--liquibase formatted sql +-- SPDX-License-Identifier: BUSL-1.1 +-- SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +--changeset fincore:payments-001-schema dbms:postgresql +CREATE SCHEMA IF NOT EXISTS payments; diff --git a/services/payments/src/main/resources/db/changelog/v0.1/010-payments.sql b/services/payments/src/main/resources/db/changelog/v0.1/010-payments.sql new file mode 100644 index 0000000..dd40c33 --- /dev/null +++ b/services/payments/src/main/resources/db/changelog/v0.1/010-payments.sql @@ -0,0 +1,18 @@ +--liquibase formatted sql +-- SPDX-License-Identifier: BUSL-1.1 +-- SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +--changeset fincore:payments-010-payments dbms:postgresql +CREATE TABLE IF NOT EXISTS payments.payments ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + reference VARCHAR(140) NOT NULL, + amount NUMERIC(38, 18) NOT NULL, + currency CHAR(3) NOT NULL, + status VARCHAR(16) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT pk_payments PRIMARY KEY (id), + CONSTRAINT ck_payments_currency CHECK (currency ~ '^[A-Z]{3}$'), + CONSTRAINT ck_payments_status + CHECK (status IN ('INITIATED', 'SCREENING', 'SUBMITTED', 'SETTLED', 'FAILED', 'CANCELLED')) +); diff --git a/services/payments/src/main/resources/db/changelog/v0.1/011-payment-events.sql b/services/payments/src/main/resources/db/changelog/v0.1/011-payment-events.sql new file mode 100644 index 0000000..47182aa --- /dev/null +++ b/services/payments/src/main/resources/db/changelog/v0.1/011-payment-events.sql @@ -0,0 +1,15 @@ +--liquibase formatted sql +-- SPDX-License-Identifier: BUSL-1.1 +-- SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +--changeset fincore:payments-011-payment-events dbms:postgresql +CREATE TABLE IF NOT EXISTS payments.payment_events ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + payment_id UUID NOT NULL, + event_type VARCHAR(128) NOT NULL, + payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT pk_payment_events PRIMARY KEY (id) +); +CREATE INDEX IF NOT EXISTS idx_payment_events_payment + ON payments.payment_events(payment_id, created_at); diff --git a/services/payments/src/main/resources/db/changelog/v0.1/012-processed-webhooks.sql b/services/payments/src/main/resources/db/changelog/v0.1/012-processed-webhooks.sql new file mode 100644 index 0000000..b5e633f --- /dev/null +++ b/services/payments/src/main/resources/db/changelog/v0.1/012-processed-webhooks.sql @@ -0,0 +1,10 @@ +--liquibase formatted sql +-- SPDX-License-Identifier: BUSL-1.1 +-- SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +--changeset fincore:payments-012-processed-webhooks dbms:postgresql +CREATE TABLE IF NOT EXISTS payments.processed_webhooks ( + delivery_id VARCHAR(200) NOT NULL, + received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT pk_processed_webhooks PRIMARY KEY (delivery_id) +); diff --git a/services/payments/src/main/resources/db/changelog/v0.1/013-outbox-events.sql b/services/payments/src/main/resources/db/changelog/v0.1/013-outbox-events.sql new file mode 100644 index 0000000..0d13b00 --- /dev/null +++ b/services/payments/src/main/resources/db/changelog/v0.1/013-outbox-events.sql @@ -0,0 +1,27 @@ +--liquibase formatted sql +-- SPDX-License-Identifier: BUSL-1.1 +-- SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +--changeset fincore:payments-013-outbox-events dbms:postgresql +CREATE TABLE IF NOT EXISTS payments.outbox_events ( + id UUID NOT NULL DEFAULT gen_random_uuid(), + aggregate_type VARCHAR(64) NOT NULL, + aggregate_id VARCHAR(64) NOT NULL, + event_type VARCHAR(128) NOT NULL, + payload JSONB NOT NULL, + status VARCHAR(24) NOT NULL DEFAULT 'PENDING', + attempts INT NOT NULL DEFAULT 0, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + published_at TIMESTAMPTZ, + leased_at TIMESTAMPTZ, + CONSTRAINT pk_payments_outbox_events PRIMARY KEY (id), + CONSTRAINT ck_payments_outbox_status + CHECK (status IN ('PENDING', 'PUBLISHING', 'PUBLISHED', 'FAILED', 'PERMANENTLY_FAILED')) +); +CREATE INDEX IF NOT EXISTS idx_payments_outbox_pending + ON payments.outbox_events(status, created_at) + WHERE status = 'PENDING'; +CREATE INDEX IF NOT EXISTS idx_payments_outbox_claimable + ON payments.outbox_events(status, created_at) + WHERE status IN ('PENDING', 'FAILED', 'PUBLISHING');