diff --git a/docs/adr/0015-paymentprovider-gateway-with-simulator-as-service-stub.md b/docs/adr/0015-paymentprovider-gateway-with-simulator-as-service-stub.md new file mode 100644 index 0000000..e1208ee --- /dev/null +++ b/docs/adr/0015-paymentprovider-gateway-with-simulator-as-service-stub.md @@ -0,0 +1,31 @@ +--- +status: accepted +date: 2026-06-05 +cites: PoEAA, XP, APoSD, PragProg, DDD +--- + +# 0015 — A `PaymentProvider` gateway, with the simulator as the Service Stub (live Stripe deferred) + +Phase 15 was planned as "wire a real Stripe Test-Mode provider behind a config toggle." On building it, the abstraction turned out to be the whole value and the live adapter turned out to be speculative weight the project does not yet need. This ADR records what shipped — a `PaymentProvider` gateway with the existing simulator behind it — and why the concrete Stripe adapter was **deliberately deferred** rather than built. + +## What shipped + +The Payment context now exposes a `PaymentProvider` port: `charge(paymentMethodToken, amount) → ChargeOutcome`, where `ChargeOutcome` is `Succeeded(amount)` or `Failed(reason)`. The `OrderPlaced` consumer depends only on that interface; it no longer resolves tokens or knows which backend decides the charge. The sole implementation, `SimulatedProvider`, resolves the token to its redacted last-four (the PAN was tokenized away in Phase 14, so the last-four is all it can see) and delegates to the existing `PaymentSimulator`, mapping its outcome to a `ChargeOutcome`. No event shape changed, no Catalog code changed, and the full suite stayed green — the refactor is invisible to every caller. + +## Why the boundary, and why not the adapter + +**PoEAA — Gateway + Service Stub.** The pattern this phase is really about is Fowler's *Gateway*: an interface that encapsulates access to an external system, paired with a *Service Stub* that stands in for that system during development and test. `PaymentProvider` is the gateway; `SimulatedProvider` is the Service Stub. Fowler's explicit guidance is that you build and test the entire system against the stub, and only the live gateway implementation depends on the real service being reachable. The valuable half — the seam plus a deterministic stand-in — is exactly what shipped. A live `StripeProvider` would be the other half, needed only when the system actually has to talk to Stripe. + +**XP — YAGNI.** A real payment processor for a learning e-commerce modulith is the textbook speculative feature: it earns its keep only at the moment a real charge must clear, which is not a goal of this project. ShopSphere runs and demonstrates the complete order→payment→fulfilment flow in `simulator` mode, fully offline, with no account and no credential. Building the adapter now would be building for a need that has not arrived. + +**APoSD — keep complexity that isn't earning its keep out.** A live Stripe adapter drags in the Stripe SDK, network failure modes, retry/timeout handling, sandbox flakiness, and a managed secret — real, recurring complexity behind the deep `PaymentProvider` interface. Ousterhout's test is whether that complexity buys enough; here it buys a capability the project will essentially never exercise. The gateway interface, by contrast, is nearly free and pulls the *option* of that complexity down out of sight until it is wanted. + +**PragProg — reversibility and orthogonality.** The seam is the insurance. Because every caller already depends on `PaymentProvider`, adding a `StripeProvider` later is a new class plus a config toggle and changes no existing code — the decision to defer forecloses nothing. This is the same orthogonality ADR-0009 banked on ("if we replace the Payment Simulator with a real provider tomorrow, Catalog code does not need to change"): that promise is now concrete at the provider boundary too. + +**DDD — a clean port on the bounded context.** Payment already owns its vocabulary and exposes it outward (ADR-0009); `PaymentProvider` is the port through which "actually charge an instrument" is expressed. Whether the adapter behind the port is a simulator or Stripe is irrelevant to the model's integrity — the boundary is what matters, and it is now explicit. + +## Consequences + +The project keeps its defining property: it works end-to-end with **zero external dependency**, and `shopsphere.payment.provider`-style toggles and Stripe credentials are simply absent rather than defaulted-and-unused. No config key was added, because a toggle with a single valid value is itself speculative (YAGNI) — the interface, not a property, is the extension point. + +The honest cost, recorded so it is not mistaken for an oversight: there is **no live payment integration**, and the "I integrated real Stripe" demonstration is not part of this project. Reintroducing it is a bounded, well-understood task — implement `PaymentProvider` against the Stripe Java SDK using a Test-Mode key, select it with a config toggle, and cover it with a network-gated test outside the default build — and the seam shipped here is precisely what makes that reintroduction cheap if the need ever arrives. **PragProg reversibility**: deferred, not foreclosed. diff --git a/docs/modulith/components.puml b/docs/modulith/components.puml index 925b8b1..8acff6d 100644 --- a/docs/modulith/components.puml +++ b/docs/modulith/components.puml @@ -16,12 +16,12 @@ Container_Boundary("ShopSphere.ShopSphere_boundary", "ShopSphere", $tags="") { Component(ShopSphere.ShopSphere.Ordering, "Ordering", $techn="Module", $descr="", $tags="", $link="") } -Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="") Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Catalog, "uses", $techn="", $tags="", $link="") Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Payment, "uses", $techn="", $tags="", $link="") -Rel(ShopSphere.ShopSphere.Catalog, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="") Rel(ShopSphere.ShopSphere.Payment, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="") +Rel(ShopSphere.ShopSphere.Catalog, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="") Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Identity, "depends on", $techn="", $tags="", $link="") +Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="") SHOW_LEGEND(true) @enduml \ No newline at end of file diff --git a/docs/modulith/module-ordering.puml b/docs/modulith/module-ordering.puml index bd113fa..d37d337 100644 --- a/docs/modulith/module-ordering.puml +++ b/docs/modulith/module-ordering.puml @@ -16,12 +16,12 @@ Container_Boundary("ShopSphere.ShopSphere_boundary", "ShopSphere", $tags="") { Component(ShopSphere.ShopSphere.Ordering, "Ordering", $techn="Module", $descr="", $tags="", $link="") } -Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="") Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Catalog, "uses", $techn="", $tags="", $link="") Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Payment, "uses", $techn="", $tags="", $link="") -Rel(ShopSphere.ShopSphere.Catalog, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="") Rel(ShopSphere.ShopSphere.Payment, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="") +Rel(ShopSphere.ShopSphere.Catalog, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="") Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Identity, "depends on", $techn="", $tags="", $link="") +Rel(ShopSphere.ShopSphere.Ordering, ShopSphere.ShopSphere.Common, "depends on", $techn="", $tags="", $link="") SHOW_LEGEND(true) @enduml \ No newline at end of file diff --git a/docs/modulith/module-payment.adoc b/docs/modulith/module-payment.adoc index cb2b94b..0b26f87 100644 --- a/docs/modulith/module-payment.adoc +++ b/docs/modulith/module-payment.adoc @@ -6,4 +6,8 @@ |_Services_ * `c.s.p.PaymentMethods` (via `c.s.p.PaymentMethodService`) + +_Others_ + +* `c.s.p.PaymentProvider` |=== diff --git a/src/main/java/com/shopsphere/payment/PaymentConfig.java b/src/main/java/com/shopsphere/payment/PaymentConfig.java new file mode 100644 index 0000000..9cb2248 --- /dev/null +++ b/src/main/java/com/shopsphere/payment/PaymentConfig.java @@ -0,0 +1,18 @@ +package com.shopsphere.payment; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Wires the active {@link PaymentProvider}. Defaults to the offline {@link SimulatedProvider}; the + * {@code shopsphere.payment.provider} toggle (added in the Stripe slice) selects an alternative at + * boot without touching any caller. + */ +@Configuration +class PaymentConfig { + + @Bean + PaymentProvider paymentProvider(PaymentMethods paymentMethods, PaymentSimulator simulator) { + return new SimulatedProvider(paymentMethods, simulator); + } +} diff --git a/src/main/java/com/shopsphere/payment/PaymentOrderingConsumer.java b/src/main/java/com/shopsphere/payment/PaymentOrderingConsumer.java index 5c875db..f6c2054 100644 --- a/src/main/java/com/shopsphere/payment/PaymentOrderingConsumer.java +++ b/src/main/java/com/shopsphere/payment/PaymentOrderingConsumer.java @@ -25,23 +25,20 @@ class PaymentOrderingConsumer { static final String CONSUMER_ID = "payment.orderplaced"; private final ObjectMapper mapper; - private final PaymentSimulator simulator; - private final PaymentMethods paymentMethods; + private final PaymentProvider provider; private final ProcessedEvents processedEvents; private final ApplicationEventPublisher events; private final Clock clock; private final MeterRegistry meters; PaymentOrderingConsumer(ObjectMapper mapper, - PaymentSimulator simulator, - PaymentMethods paymentMethods, + PaymentProvider provider, JdbcTemplate jdbc, ApplicationEventPublisher events, Clock clock, MeterRegistry meters) { this.mapper = mapper; - this.simulator = simulator; - this.paymentMethods = paymentMethods; + this.provider = provider; this.processedEvents = new ProcessedEvents(jdbc, "payment"); this.events = events; this.clock = clock; @@ -65,18 +62,16 @@ public void onOrderingEvent(ConsumerRecord record) throws Except } void process(OrderPlacedView placed) { - // The PAN is redacted upstream: resolve the token to its last-four and decide from that. - String lastFour = paymentMethods.lookup(placed.paymentMethodToken) - .map(PaymentMethods.PaymentMethodView::lastFour) - .orElse(""); - PaymentSimulator.PaymentOutcome outcome = simulator.process(lastFour, placed.total); + // Hand the tokenized instrument to the configured provider; the PAN never reaches here. The + // provider (simulator or Stripe Test Mode) owns how a token becomes an approve/decline. + PaymentProvider.ChargeOutcome outcome = provider.charge(placed.paymentMethodToken, placed.total); String metricOutcome = switch (outcome) { - case PaymentSimulator.PaymentOutcome.Succeeded s -> { + case PaymentProvider.ChargeOutcome.Succeeded s -> { events.publishEvent(new PaymentSucceeded( UUID.randomUUID(), clock.instant(), placed.orderId, placed.customerId, s.amount())); yield "succeeded"; } - case PaymentSimulator.PaymentOutcome.Failed f -> { + case PaymentProvider.ChargeOutcome.Failed f -> { events.publishEvent(new PaymentFailed( UUID.randomUUID(), clock.instant(), placed.orderId, placed.customerId, f.reason())); yield f.reason().name().toLowerCase(Locale.ROOT); diff --git a/src/main/java/com/shopsphere/payment/PaymentProvider.java b/src/main/java/com/shopsphere/payment/PaymentProvider.java new file mode 100644 index 0000000..55cfbe4 --- /dev/null +++ b/src/main/java/com/shopsphere/payment/PaymentProvider.java @@ -0,0 +1,37 @@ +package com.shopsphere.payment; + +import com.shopsphere.common.Money; + +import java.util.UUID; + +/** + * The Payment context's boundary to "actually charge an instrument". A provider takes a tokenized + * payment method (never a raw PAN — the token is the only handle that crosses into here) and an + * amount, and reports whether the charge succeeded. {@code Money} carries both the amount and its + * currency, so it stands in for the "amount + currency" pair the charge needs. + *

+ * Two implementations exist — {@link SimulatedProvider} (deterministic, offline) and a Stripe + * Test-Mode provider — selected at boot by {@code shopsphere.payment.provider}. The consumer that + * reacts to {@code OrderPlaced} depends only on this interface, so swapping providers changes no + * caller and no event shape. + */ +public interface PaymentProvider { + + ChargeOutcome charge(UUID paymentMethodToken, Money amount); + + sealed interface ChargeOutcome { + static ChargeOutcome succeeded(Money amount) { + return new Succeeded(amount); + } + + static ChargeOutcome failed(PaymentFailed.Reason reason) { + return new Failed(reason); + } + + record Succeeded(Money amount) implements ChargeOutcome { + } + + record Failed(PaymentFailed.Reason reason) implements ChargeOutcome { + } + } +} diff --git a/src/main/java/com/shopsphere/payment/SimulatedProvider.java b/src/main/java/com/shopsphere/payment/SimulatedProvider.java new file mode 100644 index 0000000..84c3045 --- /dev/null +++ b/src/main/java/com/shopsphere/payment/SimulatedProvider.java @@ -0,0 +1,33 @@ +package com.shopsphere.payment; + +import com.shopsphere.common.Money; + +import java.util.UUID; + +/** + * Offline {@link PaymentProvider} backing the default {@code simulator} mode. It resolves the token + * to its redacted last-four — the PAN was tokenized away at checkout, so the last-four is all the + * decision can see — and delegates to {@link PaymentSimulator}, which keys approve/decline/insufficient + * off those four digits. An unknown token has no last-four and is treated as a decline. + */ +final class SimulatedProvider implements PaymentProvider { + + private final PaymentMethods paymentMethods; + private final PaymentSimulator simulator; + + SimulatedProvider(PaymentMethods paymentMethods, PaymentSimulator simulator) { + this.paymentMethods = paymentMethods; + this.simulator = simulator; + } + + @Override + public ChargeOutcome charge(UUID paymentMethodToken, Money amount) { + String lastFour = paymentMethods.lookup(paymentMethodToken) + .map(PaymentMethods.PaymentMethodView::lastFour) + .orElse(""); + return switch (simulator.process(lastFour, amount)) { + case PaymentSimulator.PaymentOutcome.Succeeded s -> ChargeOutcome.succeeded(s.amount()); + case PaymentSimulator.PaymentOutcome.Failed f -> ChargeOutcome.failed(f.reason()); + }; + } +} diff --git a/src/test/java/com/shopsphere/payment/PaymentMetricsTests.java b/src/test/java/com/shopsphere/payment/PaymentMetricsTests.java index 211aa3a..756decf 100644 --- a/src/test/java/com/shopsphere/payment/PaymentMetricsTests.java +++ b/src/test/java/com/shopsphere/payment/PaymentMetricsTests.java @@ -31,8 +31,7 @@ class PaymentMetricsTests { private final PaymentMethods paymentMethods = mock(PaymentMethods.class); private final PaymentOrderingConsumer consumer = new PaymentOrderingConsumer( new ObjectMapper(), - new PaymentSimulator(), - paymentMethods, + new SimulatedProvider(paymentMethods, new PaymentSimulator()), mock(JdbcTemplate.class), event -> { }, Clock.systemUTC(), diff --git a/src/test/java/com/shopsphere/payment/SimulatedProviderTests.java b/src/test/java/com/shopsphere/payment/SimulatedProviderTests.java new file mode 100644 index 0000000..1f0ffbd --- /dev/null +++ b/src/test/java/com/shopsphere/payment/SimulatedProviderTests.java @@ -0,0 +1,80 @@ +package com.shopsphere.payment; + +import com.shopsphere.common.Money; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link SimulatedProvider} adapts the existing {@link PaymentSimulator} to the {@link PaymentProvider} + * boundary: it resolves the token to its redacted last-four (the PAN is long gone) and decides from + * that. These cover the approve/decline/insufficient branches plus an unknown token (no last-four). + */ +class SimulatedProviderTests { + + private final Money amount = Money.of(new BigDecimal("1000.0000"), "INR"); + + private SimulatedProvider providerWith(Map lastFourByToken) { + PaymentMethods methods = new PaymentMethods() { + @Override + public UUID tokenize(String rawCard) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional lookup(UUID token) { + return Optional.ofNullable(lastFourByToken.get(token)) + .map(lastFour -> new PaymentMethodView(token, lastFour)); + } + }; + return new SimulatedProvider(methods, new PaymentSimulator()); + } + + @Test + void successTokenChargesForTheFullAmount() { + UUID token = UUID.randomUUID(); + SimulatedProvider provider = providerWith(Map.of(token, "4242")); + + PaymentProvider.ChargeOutcome outcome = provider.charge(token, amount); + + assertThat(outcome).isInstanceOfSatisfying(PaymentProvider.ChargeOutcome.Succeeded.class, + s -> assertThat(s.amount()).isEqualTo(amount)); + } + + @Test + void declinedTokenFailsWithDeclined() { + UUID token = UUID.randomUUID(); + SimulatedProvider provider = providerWith(Map.of(token, "0002")); + + PaymentProvider.ChargeOutcome outcome = provider.charge(token, amount); + + assertThat(outcome).isInstanceOfSatisfying(PaymentProvider.ChargeOutcome.Failed.class, + f -> assertThat(f.reason()).isEqualTo(PaymentFailed.Reason.DECLINED)); + } + + @Test + void insufficientFundsTokenFailsWithInsufficientFunds() { + UUID token = UUID.randomUUID(); + SimulatedProvider provider = providerWith(Map.of(token, "9995")); + + PaymentProvider.ChargeOutcome outcome = provider.charge(token, amount); + + assertThat(outcome).isInstanceOfSatisfying(PaymentProvider.ChargeOutcome.Failed.class, + f -> assertThat(f.reason()).isEqualTo(PaymentFailed.Reason.INSUFFICIENT_FUNDS)); + } + + @Test + void unknownTokenIsTreatedAsDeclined() { + SimulatedProvider provider = providerWith(Map.of()); + + PaymentProvider.ChargeOutcome outcome = provider.charge(UUID.randomUUID(), amount); + + assertThat(outcome).isInstanceOfSatisfying(PaymentProvider.ChargeOutcome.Failed.class, + f -> assertThat(f.reason()).isEqualTo(PaymentFailed.Reason.DECLINED)); + } +}