Skip to content

Commit ee754a6

Browse files
committed
Make order-confirmed/order-refunded notifications actually send, with
overridable templates
1 parent 8c1800a commit ee754a6

7 files changed

Lines changed: 247 additions & 7 deletions

File tree

commons/src/main/java/org/restheart/plugins/stripe/ProductsConfig.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,12 @@ public record DeliveryEstimateDays(int minimum, int maximum) {}
8383
/**
8484
* Notification configuration for order events.
8585
*
86-
* @param enabled whether this notification is sent
86+
* @param enabled whether this notification is sent
87+
* @param templatePath path to the HTML template, or {@code null} to use the built-in — same
88+
* meaning {@code templatePath} has on {@link NotificationConfig}, the
89+
* equivalent record for subscription notifications. A request-scoped
90+
* inline override still wins over this when present; see
91+
* {@code RequestOverrides.templateInline()} in {@code restheart-stripe}.
8792
*/
88-
public record OrderNotificationConfig(boolean enabled) {}
93+
public record OrderNotificationConfig(boolean enabled, String templatePath) {}
8994
}

stripe/src/main/java/org/restheart/stripe/StripeConfig.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,11 +178,21 @@ private ProductsConfig parseProducts() {
178178
}
179179
}
180180

181+
// Same shape as SubscriptionsConfig's own notifications/templates split — a sibling
182+
// "templates" map keyed by notification name, not a "template" field nested under each
183+
// notification. Keeping the two conventions identical is what lets
184+
// StripeTenantConfigInterceptor (Cloud) attach every override-stripe-tmpl-{name} with one
185+
// parser instead of two.
186+
var orderTemplatesConf = prodMap.get("templates") instanceof Map<?, ?> tm
187+
? (Map<String, Object>) tm
188+
: Map.<String, Object>of();
189+
181190
var orderNotifications = new HashMap<String, ProductsConfig.OrderNotificationConfig>();
182191
if (prodMap.get("notifications") instanceof Map<?, ?> nm) {
183192
for (var name : new String[]{"order-confirmed", "order-refunded"}) {
184193
if (nm.get(name) instanceof Map<?, ?> onm && onm.get("enabled") instanceof Boolean b) {
185-
orderNotifications.put(name, new ProductsConfig.OrderNotificationConfig(b));
194+
var template = orderTemplatesConf.get(name) instanceof String s && !s.isBlank() ? s : null;
195+
orderNotifications.put(name, new ProductsConfig.OrderNotificationConfig(b, template));
186196
}
187197
}
188198
}

stripe/src/main/java/org/restheart/stripe/util/RequestOverrides.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,15 @@ public final class RequestOverrides {
9999
public static final String CANCEL_URL = "override-stripe-cancel-url";
100100
public static final String PORTAL_RETURN_URL = "override-stripe-portal-return-url";
101101

102-
/** {@code override-stripe-tmpl-{name}} — inline HTML for a notification template. */
102+
/**
103+
* {@code override-stripe-tmpl-{name}} — inline HTML for a notification template.
104+
*
105+
* <p>Generic on {@code name}: it covers subscription notifications
106+
* ({@link org.restheart.plugins.stripe.NotificationConfig#PAYMENT_FAILED} and friends) and
107+
* order notifications ({@code order-confirmed}, {@code order-refunded}) alike, with no
108+
* per-mode wiring needed on either side — {@link #templateInline} does not know or care which
109+
* mode a given name belongs to, and neither does the caller that attaches it.
110+
*/
103111
public static final String TMPL_PREFIX = "override-stripe-tmpl-";
104112

105113
/** {@code override-stripe-notify-{name}-enabled}. */
@@ -209,7 +217,11 @@ public static boolean productsDisabled(ServiceRequest<?> req) {
209217
return req.attachedParam(PRODUCTS_DISABLED) != null;
210218
}
211219

212-
/** Inline HTML override for a notification template, or {@code null} if not overridden. */
220+
/**
221+
* Inline HTML override for a notification template, or {@code null} if not overridden.
222+
* {@code notificationName} may name a subscription or an order notification — see
223+
* {@link #TMPL_PREFIX}.
224+
*/
213225
public static String templateInline(ServiceRequest<?> req, String notificationName) {
214226
var v = req.attachedParam(TMPL_PREFIX + notificationName);
215227
return (v instanceof String s && !s.isBlank()) ? s : null;

stripe/src/main/java/org/restheart/stripe/webhook/OrderEventHandler.java

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import org.restheart.emails.EmailSender;
3434
import org.restheart.emails.EmailTemplateLoader;
3535
import org.restheart.plugins.stripe.ProductsConfig;
36+
import org.restheart.stripe.util.RequestOverrides;
3637
import org.slf4j.Logger;
3738
import org.slf4j.LoggerFactory;
3839

@@ -571,9 +572,20 @@ private void sendOrderNotification(StripeEventContext ctx, ProductsConfig produc
571572
var vars = new java.util.HashMap<String, String>();
572573
vars.put("order-id", orderId.toHexString());
573574
vars.put("amount", String.valueOf(amount));
575+
vars.put("amount-formatted", formatAmount(amount, currency));
574576
vars.put("currency", currency);
575-
576-
var raw = EmailTemplateLoader.loadWithFallback(null, null, name + ".html");
577+
// Same convention StripeNotifications.send() uses for subscriptions — kept in step
578+
// with it deliberately, not reinvented here. "App" is the fallback because, unlike
579+
// subscriptions, nothing upstream of this handler resolves a tenant's real app name.
580+
vars.putIfAbsent("year", String.valueOf(java.time.Year.now().getValue()));
581+
vars.putIfAbsent("app-name", "App");
582+
583+
// Same inline > path > built-in precedence as subscription notifications, and the
584+
// same override key convention: override-stripe-tmpl-{name} is generic on the
585+
// notification name, so it already covers order-confirmed/order-refunded without any
586+
// dedicated wiring — see RequestOverrides.templateInline().
587+
var inline = RequestOverrides.templateInline(ctx.req(), name);
588+
var raw = EmailTemplateLoader.loadWithFallback(inline, notification.templatePath(), name + ".html");
577589
var rendered = EmailRenderer.render(raw, vars, "en");
578590

579591
emailSender.sendEmailAsync(ctx.req(), email, email, rendered.subject(), rendered.htmlBody());
@@ -582,4 +594,30 @@ private void sendOrderNotification(StripeEventContext ctx, ProductsConfig produc
582594
LOGGER.error("[stripe] failed to send '{}' notification for order {}: {}", name, orderId, e.getMessage());
583595
}
584596
}
597+
598+
/**
599+
* {@code amountMinorUnits} converted to the currency's major unit, formatted with that
600+
* currency's own number of fraction digits — 2 for EUR/USD, 0 for JPY, 3 for BHD, etc. Stripe
601+
* amounts are always in the minor unit (docs.stripe.com/currencies#zero-decimal), so a naive
602+
* "divide by 100" would misrender any zero- or three-decimal currency.
603+
*
604+
* <p>Falls back to 2 fraction digits for a currency code {@link java.util.Currency} does not
605+
* recognize (Stripe supports a few it does not, e.g. some historical or crypto-adjacent
606+
* codes) — the common case, better than failing the whole notification over formatting.
607+
*/
608+
static String formatAmount(long amountMinorUnits, String currencyCode) {
609+
int fractionDigits;
610+
try {
611+
fractionDigits = java.util.Currency.getInstance(currencyCode.toUpperCase()).getDefaultFractionDigits();
612+
if (fractionDigits < 0) {
613+
fractionDigits = 2;
614+
}
615+
} catch (IllegalArgumentException e) {
616+
fractionDigits = 2;
617+
}
618+
619+
// BigDecimal.valueOf(unscaled, scale) is amountMinorUnits * 10^-fractionDigits by
620+
// definition — exact, no division and no rounding mode to get wrong.
621+
return java.math.BigDecimal.valueOf(amountMinorUnits, fractionDigits).toPlainString();
622+
}
585623
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>
7+
<span lang="en">Order confirmed — {{app-name}}</span>
8+
<span lang="it">Ordine confermato — {{app-name}}</span>
9+
</title>
10+
</head>
11+
<body style="margin:0;padding:0;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
12+
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
13+
<tr><td align="center" style="padding:0 16px;">
14+
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,0.08);">
15+
16+
<tr>
17+
<td style="background:#1e293b;padding:28px 40px;">
18+
<span style="font-size:20px;font-weight:700;color:#ffffff;">{{app-name}}</span>
19+
</td>
20+
</tr>
21+
22+
<tr>
23+
<td style="padding:40px;color:#111827;">
24+
<p style="margin:0 0 16px;font-size:20px;font-weight:700;color:#065f46;">
25+
<span lang="en">Thanks for your order</span>
26+
<span lang="it">Grazie per il tuo ordine</span>
27+
</p>
28+
29+
<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#374151;">
30+
<span lang="en">Hi,<br>we've received your payment and your order is confirmed.</span>
31+
<span lang="it">Ciao,<br>abbiamo ricevuto il tuo pagamento e il tuo ordine è confermato.</span>
32+
</p>
33+
34+
<table cellpadding="0" cellspacing="0" width="100%" style="margin:0 0 24px;border:1px solid #f3f4f6;border-radius:6px;">
35+
<tr>
36+
<td style="padding:16px 20px;border-bottom:1px solid #f3f4f6;font-size:13px;color:#6b7280;">
37+
<span lang="en">Order</span>
38+
<span lang="it">Ordine</span>
39+
</td>
40+
<td style="padding:16px 20px;border-bottom:1px solid #f3f4f6;font-size:13px;color:#111827;text-align:right;font-family:monospace;">{{order-id}}</td>
41+
</tr>
42+
<tr>
43+
<td style="padding:16px 20px;font-size:13px;color:#6b7280;">
44+
<span lang="en">Amount paid</span>
45+
<span lang="it">Importo pagato</span>
46+
</td>
47+
<td style="padding:16px 20px;font-size:15px;font-weight:600;color:#111827;text-align:right;">{{amount-formatted}} {{currency}}</td>
48+
</tr>
49+
</table>
50+
51+
<p style="margin:0;font-size:13px;color:#9ca3af;line-height:1.5;">
52+
<span lang="en">Keep this email as your receipt. If you have any questions about your order, contact us and reference the order number above.</span>
53+
<span lang="it">Conserva questa email come ricevuta. Per qualsiasi domanda sul tuo ordine, contattaci citando il numero d'ordine sopra.</span>
54+
</p>
55+
</td>
56+
</tr>
57+
58+
<tr>
59+
<td style="padding:20px 40px;border-top:1px solid #f3f4f6;background:#f9fafb;">
60+
<p style="margin:0;font-size:12px;color:#9ca3af;text-align:center;">© {{year}} {{app-name}}</p>
61+
</td>
62+
</tr>
63+
64+
</table>
65+
</td></tr>
66+
</table>
67+
</body>
68+
</html>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>
7+
<span lang="en">Refund issued — {{app-name}}</span>
8+
<span lang="it">Rimborso emesso — {{app-name}}</span>
9+
</title>
10+
</head>
11+
<body style="margin:0;padding:0;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
12+
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
13+
<tr><td align="center" style="padding:0 16px;">
14+
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,0.08);">
15+
16+
<tr>
17+
<td style="background:#1e293b;padding:28px 40px;">
18+
<span style="font-size:20px;font-weight:700;color:#ffffff;">{{app-name}}</span>
19+
</td>
20+
</tr>
21+
22+
<tr>
23+
<td style="padding:40px;color:#111827;">
24+
<p style="margin:0 0 16px;font-size:20px;font-weight:700;color:#111827;">
25+
<span lang="en">Your refund has been issued</span>
26+
<span lang="it">Il tuo rimborso è stato emesso</span>
27+
</p>
28+
29+
<p style="margin:0 0 24px;font-size:15px;line-height:1.6;color:#374151;">
30+
<span lang="en">Hi,<br>we've refunded your order. The amount will appear on your original payment method within a few business days, depending on your bank.</span>
31+
<span lang="it">Ciao,<br>abbiamo rimborsato il tuo ordine. L'importo apparirà sul tuo metodo di pagamento originale entro qualche giorno lavorativo, a seconda della tua banca.</span>
32+
</p>
33+
34+
<table cellpadding="0" cellspacing="0" width="100%" style="margin:0 0 24px;border:1px solid #f3f4f6;border-radius:6px;">
35+
<tr>
36+
<td style="padding:16px 20px;border-bottom:1px solid #f3f4f6;font-size:13px;color:#6b7280;">
37+
<span lang="en">Order</span>
38+
<span lang="it">Ordine</span>
39+
</td>
40+
<td style="padding:16px 20px;border-bottom:1px solid #f3f4f6;font-size:13px;color:#111827;text-align:right;font-family:monospace;">{{order-id}}</td>
41+
</tr>
42+
<tr>
43+
<td style="padding:16px 20px;font-size:13px;color:#6b7280;">
44+
<span lang="en">Amount refunded</span>
45+
<span lang="it">Importo rimborsato</span>
46+
</td>
47+
<td style="padding:16px 20px;font-size:15px;font-weight:600;color:#111827;text-align:right;">{{amount-formatted}} {{currency}}</td>
48+
</tr>
49+
</table>
50+
51+
<p style="margin:0;font-size:13px;color:#9ca3af;line-height:1.5;">
52+
<span lang="en">If you have any questions about this refund, contact us and reference the order number above.</span>
53+
<span lang="it">Per qualsiasi domanda su questo rimborso, contattaci citando il numero d'ordine sopra.</span>
54+
</p>
55+
</td>
56+
</tr>
57+
58+
<tr>
59+
<td style="padding:20px 40px;border-top:1px solid #f3f4f6;background:#f9fafb;">
60+
<p style="margin:0;font-size:12px;color:#9ca3af;text-align:center;">© {{year}} {{app-name}}</p>
61+
</td>
62+
</tr>
63+
64+
</table>
65+
</td></tr>
66+
</table>
67+
</body>
68+
</html>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package org.restheart.stripe.webhook;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
5+
import org.junit.jupiter.api.DisplayName;
6+
import org.junit.jupiter.api.Test;
7+
8+
@DisplayName("OrderEventHandler.formatAmount Tests")
9+
class OrderEventHandlerTest {
10+
11+
@Test
12+
@DisplayName("a two-decimal currency divides the minor unit by 100")
13+
void twoDecimalCurrency() {
14+
assertEquals("19.90", OrderEventHandler.formatAmount(1990, "eur"));
15+
assertEquals("19.90", OrderEventHandler.formatAmount(1990, "EUR"));
16+
assertEquals("0.05", OrderEventHandler.formatAmount(5, "usd"));
17+
assertEquals("100.00", OrderEventHandler.formatAmount(10000, "usd"));
18+
}
19+
20+
@Test
21+
@DisplayName("a zero-decimal currency is not divided at all")
22+
void zeroDecimalCurrency() {
23+
// JPY has no minor unit — Stripe amounts for it are already whole yen.
24+
assertEquals("500", OrderEventHandler.formatAmount(500, "jpy"));
25+
}
26+
27+
@Test
28+
@DisplayName("a three-decimal currency divides by 1000")
29+
void threeDecimalCurrency() {
30+
// BHD (Bahraini dinar) — the case a naive amount/100 would silently misrender.
31+
assertEquals("19.900", OrderEventHandler.formatAmount(19900, "bhd"));
32+
}
33+
34+
@Test
35+
@DisplayName("an unrecognized currency code falls back to two decimals rather than failing")
36+
void unknownCurrencyCode_fallsBackToTwoDecimals() {
37+
assertEquals("19.90", OrderEventHandler.formatAmount(1990, "xyz"));
38+
}
39+
}

0 commit comments

Comments
 (0)