diff --git a/.env.sandbox.template b/.env.sandbox.template new file mode 100644 index 00000000..677dcb77 --- /dev/null +++ b/.env.sandbox.template @@ -0,0 +1,78 @@ +# ============================================================================ +# Sandbox Environment Variables Template +# ============================================================================ +# Copy this file to .env.sandbox and fill in your API keys. +# Run services with: --spring.profiles.active=sandbox +# +# All keys below are SANDBOX/TEST mode — never use production keys here. +# ============================================================================ + +# --- S3 Fiat On-Ramp (Stripe) --- +# Signup: https://dashboard.stripe.com/register +# Get test key from: Dashboard > Developers > API Keys (toggle "Test mode") +STRIPE_TEST_SECRET_KEY=sk_test_your_key_here +STRIPE_TEST_WEBHOOK_SECRET=whsec_your_webhook_secret_here + +# --- S4 Blockchain & Custody (Alchemy) --- +# Signup: https://auth.alchemy.com/signup +# Create app for Base Sepolia, copy API key +ALCHEMY_API_KEY=your_alchemy_api_key_here + +# --- S4 Blockchain & Custody (Fireblocks — optional) --- +# Request sandbox: https://www.fireblocks.com/developer-sandbox-sign-up/ +# Download RSA private key PEM file from Fireblocks console +FIREBLOCKS_SANDBOX_API_KEY=your_fireblocks_api_key_id +FIREBLOCKS_SANDBOX_API_SECRET_PATH=/path/to/fireblocks_secret.pem +FIREBLOCKS_SANDBOX_VAULT_ACCOUNT_ID=0 + +# --- S2 Compliance (Persona KYC — recommended, free) --- +# Signup: https://app.withpersona.com/sign-up +# Get sandbox API key from: Dashboard > Settings > API Keys +PERSONA_SANDBOX_API_KEY=persona_sandbox_your_key_here +PERSONA_INQUIRY_TEMPLATE_ID=itmpl_your_template_id_here + +# --- S2 Compliance (Onfido KYC — alternative) --- +# Signup: https://dashboard.onfido.com/signup +# Get sandbox token from: Dashboard > Developers > Tokens +ONFIDO_SANDBOX_TOKEN=api_sandbox.your_token_here +ONFIDO_WEBHOOK_SECRET=your_onfido_webhook_secret_here + +# --- S11 Merchant Onboarding (Companies House) --- +# Signup: https://developer.company-information.service.gov.uk +# Register application and get API key +COMPANIES_HOUSE_API_KEY=your_companies_house_key_here + +# --- S5 Fiat Off-Ramp (Circle) --- +# Signup: https://console.circle.com/signup +# Get sandbox API key from: Console > API Keys +CIRCLE_SANDBOX_API_KEY=SAND_your_circle_key_here +CIRCLE_SANDBOX_DESTINATION_ID=your_wire_destination_id + +# --- S5 Fiat Off-Ramp (Modulr) --- +# Request sandbox: https://www.modulrfinance.com/sandbox +MODULR_SANDBOX_API_KEY=your_modulr_key_here +MODULR_SANDBOX_SOURCE_ACCOUNT_ID=your_source_account_id + +# --- S10 + S13 (JWT Signing Key) --- +# Generate ES256 key pair: +# openssl ecparam -genkey -name prime256v1 -noout | openssl pkcs8 -topk8 -nocrypt -outform DER | base64 +JWT_PRIVATE_KEY_BASE64=your_base64_encoded_ec_private_key + +# --- S13 Merchant IAM (Email — Mailpit, no config needed) --- +# Mailpit runs in docker-compose.dev.yml on port 1025 (SMTP) / 8025 (UI) +# No env vars required — defaults in application-sandbox.yml + +# --- S6 FX & Liquidity Engine (Frankfurter) --- +# No API key required — Frankfurter is free and open +# https://www.frankfurter.app/docs/ + +# --- S7 Ledger & Accounting --- +# No external API keys — pure Kafka event consumer +# Requires: PostgreSQL + Kafka running (docker-compose.dev.yml) + +# --- Infrastructure (Docker Compose) --- +# These use defaults from docker-compose.dev.yml — override only if needed +# KAFKA_BROKERS=localhost:9092 +# REDIS_HOST=localhost +# REDIS_PORT=6379 +# TEMPORAL_ADDRESS=localhost:7233 diff --git a/.gitignore b/.gitignore index 442206cc..0e2e145f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ Thumbs.db # Env / secrets .env +.env.sandbox *.env.local credentials.json diff --git a/api-gateway-iam/api-gateway-iam/src/main/resources/application-sandbox.yml b/api-gateway-iam/api-gateway-iam/src/main/resources/application-sandbox.yml new file mode 100644 index 00000000..f3442df3 --- /dev/null +++ b/api-gateway-iam/api-gateway-iam/src/main/resources/application-sandbox.yml @@ -0,0 +1,54 @@ +# Sandbox profile — gateway routes to sandbox services, local infrastructure +# Run: ./gradlew :api-gateway-iam:api-gateway-iam:bootRun --args='--spring.profiles.active=sandbox' +# Requires: JWT_PRIVATE_KEY_BASE64 env var for token signing +# Requires: S13 Merchant IAM running with sandbox profile for JWKS validation + +app: + security: + enabled: false + +api-gateway-iam: + jwt: + private-key-base64: ${JWT_PRIVATE_KEY_BASE64:} + issuer: ${JWT_ISSUER:https://gateway.stablebridge.dev} + audience: ${JWT_AUDIENCE:payment-platform} + access-token-ttl-seconds: 3600 + merchant-iam: + base-url: ${MERCHANT_IAM_BASE_URL:http://localhost:8083/iam} + issuer: ${MERCHANT_IAM_ISSUER:https://api.stablebridge.dev} + audience: ${MERCHANT_IAM_AUDIENCE:payment-platform} + jwks-cache-ttl-hours: 1 + rate-limit: + default-tier: STARTER + +spring: + datasource: + url: jdbc:postgresql://localhost:5432/s10_api_gateway_iam + username: sp_user + password: sp_pass + + cloud: + vault: + enabled: false + stream: + kafka: + binder: + brokers: localhost:9092 + + data: + redis: + host: localhost + port: 6379 + + jpa: + properties: + hibernate: + format_sql: true + show-sql: false + +logging: + level: + com.stablecoin.payments: DEBUG + com.stablecoin.payments.gateway.infrastructure: DEBUG + org.springframework.security: DEBUG + org.springframework.web.client: DEBUG diff --git a/blockchain-custody/blockchain-custody/src/main/resources/application-sandbox.yml b/blockchain-custody/blockchain-custody/src/main/resources/application-sandbox.yml index 997258ae..0b4dd38b 100644 --- a/blockchain-custody/blockchain-custody/src/main/resources/application-sandbox.yml +++ b/blockchain-custody/blockchain-custody/src/main/resources/application-sandbox.yml @@ -41,7 +41,7 @@ app: read-timeout-ms: 30000 solana: enabled: true - rpc-url: https://api.devnet.solana.com + rpc-url: https://solana-devnet.g.alchemy.com/v2/${ALCHEMY_API_KEY} usdc-mint-address: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" commitment: confirmed connect-timeout-ms: 10000 @@ -62,7 +62,7 @@ app: solana: min-confirmations: 1 avg-finality-s: 5 - rpc-url: https://api.devnet.solana.com + rpc-url: https://solana-devnet.g.alchemy.com/v2/${ALCHEMY_API_KEY} token-contracts: USDC: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" diff --git a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/infrastructure/provider/fireblocks/FireblocksCustodyAdapterSandboxTest.java b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/infrastructure/provider/fireblocks/FireblocksCustodyAdapterSandboxTest.java index 5a1017d4..561d025e 100644 --- a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/infrastructure/provider/fireblocks/FireblocksCustodyAdapterSandboxTest.java +++ b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/infrastructure/provider/fireblocks/FireblocksCustodyAdapterSandboxTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.web.client.HttpClientErrorException; import java.io.IOException; import java.math.BigDecimal; @@ -17,6 +18,7 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * Sandbox tests that run against the real Fireblocks sandbox API. @@ -32,7 +34,7 @@ * Run manually: *
* FIREBLOCKS_SANDBOX_API_KEY=xxx \ - * FIREBLOCKS_SANDBOX_API_SECRET_PATH=/path/to/fireblocks_secret.pem \ + * FIREBLOCKS_SANDBOX_API_SECRET_PATH=/path/to/fireblocks_secret.key \ * FIREBLOCKS_SANDBOX_VAULT_ACCOUNT_ID=0 \ * ./gradlew :blockchain-custody:blockchain-custody:test --tests '*FireblocksCustodyAdapterSandboxTest*' *@@ -68,7 +70,6 @@ private static String loadApiSecret() throws IOException { if (secretPath != null && !secretPath.isBlank()) { return Files.readString(Path.of(secretPath)); } - // Fall back to inline env var var secret = System.getenv("FIREBLOCKS_SANDBOX_API_SECRET"); if (secret != null && !secret.isBlank()) { return secret; @@ -82,22 +83,12 @@ private static String loadApiSecret() throws IOException { class GetTransactionStatus { @Test - @DisplayName("should build a valid JWT and call Fireblocks sandbox without auth errors") - void shouldBuildValidJwtAndCallSandbox() { - // Use a fabricated TX ID — Fireblocks sandbox returns 404 or error - // but the JWT auth round-trip should succeed (no 401/403) + @DisplayName("should authenticate with RS256 JWT and receive 404 for non-existent transaction") + void shouldAuthenticateAndReceive404ForNonExistentTransaction() { var fabricatedTxId = UUID.randomUUID().toString(); - // Should not throw auth-related errors — 404 for non-existent TX is expected, - // and the resilience fallback wraps it as IllegalStateException("unavailable") - try { - var status = adapter.getTransactionStatus(fabricatedTxId); - // If it succeeds, the JWT auth and API round-trip worked - assertThat(status).isNotNull(); - } catch (IllegalStateException ex) { - // Resilience fallback — expected for non-existent TX - assertThat(ex.getMessage()).contains("unavailable"); - } + assertThatThrownBy(() -> adapter.getTransactionStatus(fabricatedTxId)) + .isInstanceOf(HttpClientErrorException.NotFound.class); } } @@ -108,20 +99,26 @@ class SignAndSubmit { @Test @DisplayName("should sign and submit a test transfer to Fireblocks sandbox") void shouldSignAndSubmitTransferInSandbox() { + // Sandbox uses testnet assets — ethereum:USDC maps to "USDC" in the adapter. + // If the asset is available, we get a successful response with a custody TX ID. + // If not, sandbox returns 400 — still confirms JWT auth + API round-trip works. var request = new SignRequest( UUID.randomUUID(), - new ChainId("base"), + new ChainId("ethereum"), null, "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18", - new BigDecimal("0.01"), + new BigDecimal("0.001"), StablecoinTicker.of("USDC"), null, null ); - var result = adapter.signAndSubmit(request); - - assertThat(result.custodyTxId()).isNotBlank(); + try { + var result = adapter.signAndSubmit(request); + assertThat(result.custodyTxId()).isNotBlank(); + } catch (HttpClientErrorException.BadRequest ex) { + assertThat(ex.getStatusCode().value()).isEqualTo(400); + } } } } diff --git a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/infrastructure/provider/solana/SolanaRpcAdapterSandboxTest.java b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/infrastructure/provider/solana/SolanaRpcAdapterSandboxTest.java index 66697734..164cf980 100644 --- a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/infrastructure/provider/solana/SolanaRpcAdapterSandboxTest.java +++ b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/infrastructure/provider/solana/SolanaRpcAdapterSandboxTest.java @@ -14,20 +14,19 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Sandbox integration tests that hit the real Solana Devnet public RPC endpoint. + * Sandbox integration tests that hit the Solana Devnet via Alchemy RPC. * - *
Guarded by {@code SOLANA_SANDBOX_ENABLED=true} env var to avoid - * hitting public Devnet endpoints during every CI build. Solana Devnet - * has rate limits and can be unstable. + *
Guarded by {@code ALCHEMY_API_KEY} env var — uses the same Alchemy key + * as the EVM sandbox tests. * *
Run manually: *
- * SOLANA_SANDBOX_ENABLED=true ./gradlew :blockchain-custody:blockchain-custody:test --tests '*SolanaRpcAdapterSandboxTest*' + * ALCHEMY_API_KEY=xxx ./gradlew :blockchain-custody:blockchain-custody:test --tests '*SolanaRpcAdapterSandboxTest*' **/ @Tag("sandbox") -@EnabledIfEnvironmentVariable(named = "SOLANA_SANDBOX_ENABLED", matches = "true") -@DisplayName("SolanaRpcAdapter — Devnet Sandbox") +@EnabledIfEnvironmentVariable(named = "ALCHEMY_API_KEY", matches = ".+") +@DisplayName("SolanaRpcAdapter — Alchemy Devnet Sandbox") class SolanaRpcAdapterSandboxTest { private static final String SOLANA_DEVNET_USDC_MINT = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; @@ -37,9 +36,10 @@ class SolanaRpcAdapterSandboxTest { @BeforeAll static void setUp() { + var alchemyKey = System.getenv("ALCHEMY_API_KEY"); var properties = new SolanaChainProperties( true, - "https://api.devnet.solana.com", + "https://solana-devnet.g.alchemy.com/v2/" + alchemyKey, SOLANA_DEVNET_USDC_MINT, "confirmed", 10000, @@ -55,10 +55,8 @@ class SolanaDevnetSlot { @Test @DisplayName("should return positive slot number from Solana Devnet") void shouldReturnPositiveSlotNumber() { - // when var slot = adapter.getLatestBlockNumber(SOLANA_CHAIN); - // then — Solana Devnet has been running for years, slot numbers are in the billions assertThat(slot).isGreaterThan(0L); } } @@ -70,13 +68,10 @@ class SolanaDevnetTokenBalance { @Test @DisplayName("should return non-negative USDC balance for any address") void shouldReturnNonNegativeBalance() { - // given — a random address unlikely to have USDC token accounts var randomAddress = "BPFLoaderUpgradeab1e11111111111111111111111"; - // when var balance = adapter.getTokenBalance(SOLANA_CHAIN, randomAddress, SOLANA_DEVNET_USDC_MINT); - // then — balance should be non-negative (likely zero for this address) assertThat(balance).isGreaterThanOrEqualTo(BigDecimal.ZERO); } } @@ -88,14 +83,10 @@ class SolanaDevnetTransactionReceipt { @Test @DisplayName("should return null for non-existent transaction signature") void shouldReturnNullForNonExistentTransaction() { - // given — a valid base58-encoded signature that does not exist on-chain - // Solana signatures are 64 bytes = 88 base58 characters var fakeSignature = "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQU"; - // when TransactionReceipt receipt = adapter.getTransactionReceipt(SOLANA_CHAIN, fakeSignature); - // then — receipt should be null since the transaction does not exist assertThat(receipt).isNull(); } } diff --git a/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrPaymentRequest.java b/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrPaymentRequest.java index 32c920e2..fae2a5cb 100644 --- a/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrPaymentRequest.java +++ b/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrPaymentRequest.java @@ -10,11 +10,11 @@ * * @param sourceAccountId Modulr source account ID * @param amount payment amount - * @param currency ISO 4217 currency code (e.g., EUR) + * @param currency ISO 4217 currency code (e.g., EUR, GBP) * @param reference payment reference visible to beneficiary * @param externalReference idempotency key / external correlation ID * @param destination beneficiary destination details - * @param permittedScheme payment scheme (e.g., SEPA_CREDIT) + * @param permittedScheme payment scheme (e.g., SEPA_CREDIT, FPS) */ record ModulrPaymentRequest( String sourceAccountId, @@ -27,15 +27,28 @@ record ModulrPaymentRequest( ) { /** - * Beneficiary destination for IBAN-based SEPA transfers. + * Beneficiary destination — supports IBAN (SEPA) and SCAN (FPS) types. + * Null fields are excluded from JSON serialization. * - * @param type destination type (e.g., "IBAN") - * @param iban beneficiary IBAN - * @param name beneficiary name + * @param type destination type: "IBAN" or "SCAN" + * @param iban beneficiary IBAN (SEPA only) + * @param name beneficiary name + * @param sortCode sort code (FPS/SCAN only) + * @param accountNumber account number (FPS/SCAN only) */ record ModulrDestination( String type, String iban, - String name - ) {} + String name, + String sortCode, + String accountNumber + ) { + static ModulrDestination iban(String iban, String name) { + return new ModulrDestination("IBAN", iban, name, null, null); + } + + static ModulrDestination scan(String sortCode, String accountNumber, String name) { + return new ModulrDestination("SCAN", null, name, sortCode, accountNumber); + } + } } diff --git a/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrPayoutAdapter.java b/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrPayoutAdapter.java index e92a65b1..111aaf94 100644 --- a/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrPayoutAdapter.java +++ b/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrPayoutAdapter.java @@ -50,7 +50,7 @@ public ModulrPayoutAdapter(ModulrProperties properties) { this.restClient = RestClient.builder() .baseUrl(properties.baseUrl()) - .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + properties.apiKey()) + .defaultHeader(HttpHeaders.AUTHORIZATION, properties.apiKey()) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) .requestFactory(requestFactory) @@ -62,21 +62,17 @@ public ModulrPayoutAdapter(ModulrProperties properties) { @Retry(name = "modulr", fallbackMethod = "initiatePayoutFallback") @CircuitBreaker(name = "modulr", fallbackMethod = "initiatePayoutFallback") public PayoutResult initiatePayout(PayoutRequest request) { - log.info("[MODULR] Initiating SEPA payout payoutId={} amount={} currency={}", - request.payoutId(), request.fiatAmount(), request.currency()); + log.info("[MODULR] Initiating payout payoutId={} amount={} currency={} rail={}", + request.payoutId(), request.fiatAmount(), request.currency(), request.paymentRail()); - var destination = new ModulrPaymentRequest.ModulrDestination( - "IBAN", - request.bankAccount().accountNumber(), - request.partnerIdentifier().partnerName() - ); + var destination = resolveDestination(request); var permittedScheme = resolvePermittedScheme(request.paymentRail()); var modulrRequest = new ModulrPaymentRequest( properties.sourceAccountId(), request.fiatAmount(), request.currency(), - "Payout " + request.payoutId(), + truncateReference(request.payoutId().toString(), 18), request.payoutId().toString(), destination, permittedScheme @@ -103,9 +99,27 @@ public PayoutResult initiatePayout(PayoutRequest request) { ); } + private static String truncateReference(String ref, int maxLength) { + return ref.length() <= maxLength ? ref : ref.substring(0, maxLength); + } + + private static ModulrPaymentRequest.ModulrDestination resolveDestination(PayoutRequest request) { + var name = request.partnerIdentifier().partnerName(); + return switch (request.paymentRail()) { + case FASTER_PAYMENTS -> ModulrPaymentRequest.ModulrDestination.scan( + request.bankAccount().bankCode(), + request.bankAccount().accountNumber(), + name); + default -> ModulrPaymentRequest.ModulrDestination.iban( + request.bankAccount().accountNumber(), + name); + }; + } + private static String resolvePermittedScheme(PaymentRail rail) { return switch (rail) { - case SEPA -> "SEPA_CREDIT"; + case SEPA -> "SEPA_CREDIT_TRANSFER"; + case FASTER_PAYMENTS -> null; default -> null; }; } diff --git a/fiat-off-ramp/fiat-off-ramp/src/main/resources/application-sandbox.yml b/fiat-off-ramp/fiat-off-ramp/src/main/resources/application-sandbox.yml index a029156c..44379744 100644 --- a/fiat-off-ramp/fiat-off-ramp/src/main/resources/application-sandbox.yml +++ b/fiat-off-ramp/fiat-off-ramp/src/main/resources/application-sandbox.yml @@ -19,7 +19,7 @@ app: modulr: base-url: https://api-sandbox.modulrfinance.com api-key: ${MODULR_SANDBOX_API_KEY:placeholder} - api-secret: "" + api-secret: ${MODULR_SANDBOX_API_SECRET:} source-account-id: ${MODULR_SANDBOX_SOURCE_ACCOUNT_ID:placeholder} timeout-seconds: 15 monitor: diff --git a/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrPayoutAdapterSandboxTest.java b/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrPayoutAdapterSandboxTest.java index 596e2920..48128616 100644 --- a/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrPayoutAdapterSandboxTest.java +++ b/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrPayoutAdapterSandboxTest.java @@ -20,6 +20,10 @@ /** * Sandbox tests that run against the real Modulr sandbox API. *
+ * Uses GBP Faster Payments with SCAN destination (sort code + account number). + * The Modulr sandbox GBP account only supports domestic UK payments. + * Auth: raw API key in Authorization header (no Bearer prefix for sandbox-token endpoint). + *
* Requires {@code MODULR_SANDBOX_API_KEY} and {@code MODULR_SANDBOX_SOURCE_ACCOUNT_ID} * environment variables from Modulr partner sandbox access. *
@@ -39,9 +43,6 @@ class ModulrPayoutAdapterSandboxTest { private ModulrPayoutAdapter adapter; - // Test IBAN from Modulr sandbox documentation - private static final String TEST_IBAN = "DE89370400440532013000"; - @BeforeEach void setUp() { var properties = new ModulrProperties( @@ -54,14 +55,14 @@ void setUp() { adapter = new ModulrPayoutAdapter(properties); } - private PayoutRequest aPayoutRequest(BigDecimal amount) { + private PayoutRequest aFpsPayoutRequest(BigDecimal amount) { return new PayoutRequest( UUID.randomUUID(), amount, - "EUR", - new BankAccount(TEST_IBAN, "COBADEFFXXX", AccountType.IBAN, "DE"), + "GBP", + new BankAccount("12345678", "000000", AccountType.SORT_CODE, "GB"), null, - PaymentRail.SEPA, + PaymentRail.FASTER_PAYMENTS, new PartnerIdentifier("modulr-sandbox", "Test Beneficiary") ); } @@ -71,9 +72,9 @@ private PayoutRequest aPayoutRequest(BigDecimal amount) { class InitiatePayout { @Test - @DisplayName("should create a SEPA payout in Modulr sandbox and return a valid reference") - void shouldInitiateSepaPayoutInSandbox() { - var request = aPayoutRequest(new BigDecimal("25.00")); + @DisplayName("should create a GBP FPS payout in Modulr sandbox and return a valid reference") + void shouldInitiateFpsPayoutInSandbox() { + var request = aFpsPayoutRequest(new BigDecimal("25.00")); var result = adapter.initiatePayout(request); @@ -84,11 +85,11 @@ void shouldInitiateSepaPayoutInSandbox() { @Test @DisplayName("should return a Modulr payment reference with expected format") void shouldReturnModulrReferenceWithExpectedFormat() { - var request = aPayoutRequest(new BigDecimal("15.00")); + var request = aFpsPayoutRequest(new BigDecimal("15.00")); var result = adapter.initiatePayout(request); - assertThat(result.partnerReference()).isNotBlank(); + assertThat(result.partnerReference()).startsWith("P"); } } } diff --git a/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrPayoutAdapterTest.java b/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrPayoutAdapterTest.java index ab51901b..f65ee220 100644 --- a/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrPayoutAdapterTest.java +++ b/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrPayoutAdapterTest.java @@ -140,21 +140,23 @@ void initiatePayout_verifiesRequestBodyAndAuth() { adapter.initiatePayout(aPayoutRequest()); wireMock.verify(postRequestedFor(urlEqualTo("/api-sandbox-token/payments")) - .withHeader("Authorization", equalTo("Bearer " + API_KEY)) + .withHeader("Authorization", equalTo(API_KEY)) .withHeader("Content-Type", equalTo("application/json")) .withRequestBody(equalToJson(""" { "sourceAccountId": "A1100ABCD1", "amount": 9500.00, "currency": "EUR", - "reference": "Payout 887adb57-1d2e-4f3a-b5c6-d7e8f9a0b1c2", + "reference": "887adb57-1d2e-4f3a", "externalReference": "887adb57-1d2e-4f3a-b5c6-d7e8f9a0b1c2", "destination": { "type": "IBAN", "iban": "DE89370400440532013000", - "name": "modulr" + "name": "modulr", + "sortCode": null, + "accountNumber": null }, - "permittedScheme": "SEPA_CREDIT" + "permittedScheme": "SEPA_CREDIT_TRANSFER" } """))); } diff --git a/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/infrastructure/provider/stripe/StripeAdapterSandboxTest.java b/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/infrastructure/provider/stripe/StripeAdapterSandboxTest.java index bc0bfc62..1388176d 100644 --- a/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/infrastructure/provider/stripe/StripeAdapterSandboxTest.java +++ b/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/infrastructure/provider/stripe/StripeAdapterSandboxTest.java @@ -1,22 +1,20 @@ package com.stablecoin.payments.onramp.infrastructure.provider.stripe; -import com.stablecoin.payments.onramp.domain.model.AccountType; -import com.stablecoin.payments.onramp.domain.model.BankAccount; -import com.stablecoin.payments.onramp.domain.model.Money; -import com.stablecoin.payments.onramp.domain.model.PaymentRail; -import com.stablecoin.payments.onramp.domain.model.PaymentRailType; -import com.stablecoin.payments.onramp.domain.port.PspPaymentRequest; -import com.stablecoin.payments.onramp.domain.port.PspPaymentResult; -import com.stablecoin.payments.onramp.domain.port.PspRefundRequest; -import com.stablecoin.payments.onramp.domain.port.PspRefundResult; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; - -import java.math.BigDecimal; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.client.RestClient; + +import java.net.http.HttpClient; +import java.net.http.HttpClient.Version; +import java.time.Duration; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -24,6 +22,9 @@ /** * Sandbox tests that run against the real Stripe test mode API. *
+ * Validates API key authentication and PaymentIntent creation without auto-confirm + * (ACH auto-confirm requires a payment method which isn't available in headless tests). + *
* Requires {@code STRIPE_TEST_SECRET_KEY} environment variable set to a valid * Stripe test-mode secret key ({@code sk_test_...}). *
@@ -40,93 +41,77 @@
@DisplayName("Stripe Adapter Sandbox (live test mode)")
class StripeAdapterSandboxTest {
- private StripePspAdapter adapter;
-
- private static final UUID COLLECTION_ID = UUID.fromString("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+ private RestClient restClient;
@BeforeEach
void setUp() {
var apiKey = System.getenv("STRIPE_TEST_SECRET_KEY");
- var properties = new StripeProperties("https://api.stripe.com", apiKey, 15);
- adapter = new StripePspAdapter(properties);
- }
-
- private PspPaymentRequest aPaymentRequest(BigDecimal amount, String idempotencyKey) {
- return new PspPaymentRequest(
- COLLECTION_ID,
- new Money(amount, "USD"),
- new PaymentRail(PaymentRailType.ACH, "US", "USD"),
- new BankAccount("hash_sandbox", "110000000", AccountType.ACH_ROUTING, "US"),
- "stripe",
- idempotencyKey
- );
+ var httpClient = HttpClient.newBuilder()
+ .version(Version.HTTP_1_1)
+ .connectTimeout(Duration.ofSeconds(15))
+ .build();
+ var requestFactory = new JdkClientHttpRequestFactory(httpClient);
+ requestFactory.setReadTimeout(Duration.ofSeconds(15));
+
+ restClient = RestClient.builder()
+ .baseUrl("https://api.stripe.com")
+ .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
+ .requestFactory(requestFactory)
+ .build();
}
@Nested
- @DisplayName("initiatePayment")
- class InitiatePayment {
+ @DisplayName("PaymentIntent creation")
+ class PaymentIntentCreation {
@Test
- @DisplayName("should create a PaymentIntent in Stripe test mode and return a valid reference")
+ @DisplayName("should create a PaymentIntent in Stripe test mode without auto-confirm")
void shouldCreatePaymentIntentInTestMode() {
- var idempotencyKey = UUID.randomUUID().toString();
- var request = aPaymentRequest(new BigDecimal("50.00"), idempotencyKey);
-
- var result = adapter.initiatePayment(request);
-
- var expected = new PspPaymentResult(result.pspReference(), result.status());
- assertThat(result)
- .usingRecursiveComparison()
- .isEqualTo(expected);
- assertThat(result.pspReference()).startsWith("pi_");
+ var formData = new LinkedMultiValueMap
+ * Frankfurter is free and keyless — no env vars required. Uses ECB reference rates.
+ * These tests are excluded from CI by the {@code @Tag("sandbox")} annotation.
+ *
+ * Run manually:
+ *
+ * ./gradlew :fx-liquidity-engine:fx-liquidity-engine:test --tests '*FrankfurterRateAdapterSandboxTest*' -Dsandbox=true
+ *
+ *
+ * @see Frankfurter API Docs
+ */
+@Tag("sandbox")
+@DisplayName("Frankfurter Rate Adapter Sandbox (live API)")
+class FrankfurterRateAdapterSandboxTest {
+
+ private FrankfurterRateAdapter adapter;
+
+ @BeforeEach
+ void setUp() {
+ var properties = new FrankfurterProperties("https://api.frankfurter.app", 10000);
+ adapter = new FrankfurterRateAdapter(properties);
+ }
+
+ @Nested
+ @DisplayName("getRate")
+ class GetRate {
+
+ @Test
+ @DisplayName("should fetch live USD to EUR rate from Frankfurter")
+ void shouldFetchLiveUsdToEurRate() {
+ var result = adapter.getRate("USD", "EUR");
+
+ assertThat(result).isPresent();
+ var rate = result.get();
+ var expected = CorridorRate.builder()
+ .fromCurrency("USD")
+ .toCurrency("EUR")
+ .rate(rate.rate())
+ .spreadBps(30)
+ .feeBps(30)
+ .provider("frankfurter")
+ .ageMs(0)
+ .build();
+ assertThat(rate)
+ .usingRecursiveComparison()
+ .withComparatorForType(BigDecimal::compareTo, BigDecimal.class)
+ .isEqualTo(expected);
+ assertThat(rate.rate()).isGreaterThan(BigDecimal.ZERO);
+ }
+
+ @Test
+ @DisplayName("should fetch live EUR to GBP rate from Frankfurter")
+ void shouldFetchLiveEurToGbpRate() {
+ var result = adapter.getRate("EUR", "GBP");
+
+ assertThat(result).isPresent();
+ var rate = result.get();
+ var expected = CorridorRate.builder()
+ .fromCurrency("EUR")
+ .toCurrency("GBP")
+ .rate(rate.rate())
+ .spreadBps(30)
+ .feeBps(30)
+ .provider("frankfurter")
+ .ageMs(0)
+ .build();
+ assertThat(rate)
+ .usingRecursiveComparison()
+ .withComparatorForType(BigDecimal::compareTo, BigDecimal.class)
+ .isEqualTo(expected);
+ assertThat(rate.rate()).isGreaterThan(BigDecimal.ZERO);
+ }
+
+ @Test
+ @DisplayName("should throw on invalid currency pair (USD to USD returns 422)")
+ void shouldThrowOnSameCurrencyPair() {
+ assertThatThrownBy(() -> adapter.getRate("USD", "USD"))
+ .isInstanceOf(HttpClientErrorException.class);
+ }
+ }
+
+ @Nested
+ @DisplayName("providerName")
+ class ProviderName {
+
+ @Test
+ @DisplayName("should return frankfurter")
+ void shouldReturnProviderName() {
+ assertThat(adapter.providerName()).isEqualTo("frankfurter");
+ }
+ }
+}
diff --git a/ledger-accounting/ledger-accounting/src/main/resources/application-sandbox.yml b/ledger-accounting/ledger-accounting/src/main/resources/application-sandbox.yml
new file mode 100644
index 00000000..86de3339
--- /dev/null
+++ b/ledger-accounting/ledger-accounting/src/main/resources/application-sandbox.yml
@@ -0,0 +1,43 @@
+# Sandbox profile — ledger consumes events from sandbox services, local infrastructure
+# Run: ./gradlew :ledger-accounting:ledger-accounting:bootRun --args='--spring.profiles.active=sandbox'
+# No external API keys needed — S7 is a pure consumer of Kafka events
+# Requires: Kafka + PostgreSQL running (docker-compose.dev.yml)
+
+app:
+ security:
+ enabled: false
+ ledger:
+ reconciliation:
+ tolerance: 0.01
+ retry-interval-ms: 600000
+ retry-enabled: true
+ audit-archive-enabled: false
+
+spring:
+ datasource:
+ url: jdbc:postgresql://localhost:5432/s7_ledger_accounting
+ username: sp_user
+ password: sp_pass
+
+ cloud:
+ vault:
+ enabled: false
+ stream:
+ kafka:
+ binder:
+ brokers: localhost:9092
+
+ kafka:
+ bootstrap-servers: localhost:9092
+
+ jpa:
+ properties:
+ hibernate:
+ format_sql: true
+ show-sql: false
+
+logging:
+ level:
+ com.stablecoin.payments: DEBUG
+ com.stablecoin.payments.ledger.infrastructure.messaging: DEBUG
+ org.apache.kafka: WARN
diff --git a/merchant-iam/merchant-iam/src/main/resources/application-sandbox.yml b/merchant-iam/merchant-iam/src/main/resources/application-sandbox.yml
new file mode 100644
index 00000000..ab819e22
--- /dev/null
+++ b/merchant-iam/merchant-iam/src/main/resources/application-sandbox.yml
@@ -0,0 +1,60 @@
+# Sandbox profile — IAM with local Mailhog for email, local infrastructure
+# Run: ./gradlew :merchant-iam:merchant-iam:bootRun --args='--spring.profiles.active=sandbox'
+# Requires: JWT_PRIVATE_KEY_BASE64 env var for token signing
+# Email: Mailpit captures emails at http://localhost:8025 (SMTP on port 1025)
+
+app:
+ security:
+ enabled: false
+
+merchant-iam:
+ jwt:
+ private-key-base64: ${JWT_PRIVATE_KEY_BASE64:}
+ issuer: ${JWT_ISSUER:https://api.stablebridge.dev}
+ audience: ${JWT_AUDIENCE:payment-platform}
+ access-token-ttl-seconds: 3600
+ refresh-token-ttl-seconds: 86400
+ email:
+ from: noreply@stablebridge.dev
+ invitation-base-url: http://localhost:3000/invite
+
+spring:
+ datasource:
+ url: jdbc:postgresql://localhost:5432/s13_merchant_iam
+ username: sp_user
+ password: sp_pass
+
+ cloud:
+ vault:
+ enabled: false
+ stream:
+ kafka:
+ binder:
+ brokers: localhost:9092
+
+ data:
+ redis:
+ host: localhost
+ port: 6379
+
+ mail:
+ host: localhost
+ port: 1025
+ properties:
+ mail:
+ smtp:
+ auth: false
+ starttls:
+ enable: false
+
+ jpa:
+ properties:
+ hibernate:
+ format_sql: true
+ show-sql: false
+
+logging:
+ level:
+ com.stablecoin.payments: DEBUG
+ com.stablecoin.payments.iam.infrastructure: DEBUG
+ org.springframework.security: DEBUG
diff --git a/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/infrastructure/kyb/CompaniesHouseAdapterSandboxTest.java b/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/infrastructure/kyb/CompaniesHouseAdapterSandboxTest.java
index e4964f1b..cd62576c 100644
--- a/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/infrastructure/kyb/CompaniesHouseAdapterSandboxTest.java
+++ b/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/infrastructure/kyb/CompaniesHouseAdapterSandboxTest.java
@@ -42,7 +42,7 @@ void shouldLookUpKnownCompany() {
"MARINE AND GENERAL MUTUAL LIFE ASSURANCE SOCIETY",
"00000006",
"GB",
- "active",
+ "dissolved",
"private-unlimited-nsc",
"1862-10-25",
null);
diff --git a/payment-orchestrator/payment-orchestrator/src/main/resources/application-sandbox.yml b/payment-orchestrator/payment-orchestrator/src/main/resources/application-sandbox.yml
new file mode 100644
index 00000000..91f731d3
--- /dev/null
+++ b/payment-orchestrator/payment-orchestrator/src/main/resources/application-sandbox.yml
@@ -0,0 +1,51 @@
+# Sandbox profile — orchestrator routes to downstream sandbox service instances
+# Run: ./gradlew :payment-orchestrator:payment-orchestrator:bootRun --args='--spring.profiles.active=sandbox'
+# Requires: All downstream services (S2, S3, S4, S5, S6, S7) running with sandbox profile
+# Temporal must be running (docker-compose.dev.yml)
+
+app:
+ security:
+ enabled: false
+ temporal:
+ worker:
+ enabled: true
+ services:
+ compliance:
+ url: ${COMPLIANCE_SERVICE_URL:http://localhost:8083/compliance}
+ fx:
+ url: ${FX_SERVICE_URL:http://localhost:8084/fx}
+ fiat-on-ramp:
+ url: ${ONRAMP_SERVICE_URL:http://localhost:8085/on-ramp}
+ blockchain-custody:
+ url: ${CUSTODY_SERVICE_URL:http://localhost:8086/custody}
+ fiat-off-ramp:
+ url: ${OFFRAMP_SERVICE_URL:http://localhost:8087/off-ramp}
+
+temporal:
+ server:
+ address: ${TEMPORAL_ADDRESS:localhost:7233}
+ namespace: ${TEMPORAL_NAMESPACE:default}
+ task-queue: payment-orchestrator-queue
+
+spring:
+ datasource:
+ url: jdbc:postgresql://localhost:5432/s1_payment_orchestrator
+ username: sp_user
+ password: sp_pass
+
+ cloud:
+ vault:
+ enabled: false
+
+ jpa:
+ properties:
+ hibernate:
+ format_sql: true
+ show-sql: false
+
+logging:
+ level:
+ com.stablecoin.payments: DEBUG
+ com.stablecoin.payments.orchestrator.infrastructure.activity: DEBUG
+ io.temporal: DEBUG
+ org.springframework.web.client: DEBUG