Skip to main content

max / makenotwork

server: gate one-time checkout finalize on payment settlement checkout.session.completed was treated as proof of payment by every one-time handler. That holds for synchronous card payments but not for asynchronous methods (ACH/SEPA/Bacs), where Stripe fires completed with payment_status "unpaid" and settles later. Enabling any such method on a connected account would have minted license keys, recorded splits, and granted downloads before funds settled. Add payment_status (and currency) to CheckoutSessionView with a payment_settled predicate (absent field treated as settled to preserve behaviour for legacy card sessions). Route checkout.session.completed and .async_payment_succeeded through a shared dispatcher that gates the tip/guest/cart/purchase paths on settlement while leaving subscription-mode sessions unconditional; log and release on .async_payment_failed. Consolidate the three duplicated amount reconciliations into one helper that also guards against a non-USD session. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-02 21:50 UTC
Commit: 030b70c8a60fa9bfa0ea3dc024ea162e4f436e20
Parent: 39e327b
4 files changed, +319 insertions, -97 deletions
@@ -132,6 +132,30 @@ pub struct CheckoutSessionView {
132 132 /// items; absent on older/edge events, hence `Option`.
133 133 #[serde(default)]
134 134 pub amount_subtotal: Option<i64>,
135 + /// Whether Stripe has captured funds for this session: `"paid"`,
136 + /// `"unpaid"`, or `"no_payment_required"`. Synchronous card payments report
137 + /// `"paid"` on `checkout.session.completed`; asynchronous methods (ACH,
138 + /// SEPA, Bacs) report `"unpaid"` there and settle later via
139 + /// `checkout.session.async_payment_succeeded`. Absent on older/edge events,
140 + /// hence `Option` — see `payment_settled`.
141 + #[serde(default)]
142 + pub payment_status: Option<String>,
143 + /// ISO currency of the session (e.g. `"usd"`). Sessions are built
144 + /// server-side as USD; a non-USD value makes the integer-cents subtotal
145 + /// reconciliation meaningless and is itself an anomaly. Absent on
146 + /// older/edge events, hence `Option`.
147 + #[serde(default)]
148 + pub currency: Option<String>,
149 + }
150 +
151 + impl CheckoutSessionView {
152 + /// True when funds are captured (or none were required) and it is safe to
153 + /// deliver goods. Treats an absent field as settled to preserve behaviour
154 + /// for legacy/edge events that predate the field; only an explicit
155 + /// `"unpaid"` (an async method awaiting settlement) is withheld.
156 + pub fn payment_settled(&self) -> bool {
157 + matches!(self.payment_status.as_deref(), None | Some("paid") | Some("no_payment_required"))
158 + }
135 159 }
136 160
137 161 #[derive(Debug, Default, serde::Deserialize)]
@@ -473,6 +497,54 @@ mod tests {
473 497 assert_eq!(session.mode, stripe_shared::CheckoutSessionMode::Payment);
474 498 }
475 499
500 + // --- CheckoutSessionView payment settlement gate ---
501 +
502 + fn view_with_status(status: Option<&str>) -> CheckoutSessionView {
503 + CheckoutSessionView {
504 + payment_status: status.map(str::to_string),
505 + ..Default::default()
506 + }
507 + }
508 +
509 + #[test]
510 + fn payment_settled_true_for_paid_and_no_payment_required() {
511 + assert!(view_with_status(Some("paid")).payment_settled());
512 + assert!(view_with_status(Some("no_payment_required")).payment_settled());
513 + }
514 +
515 + #[test]
516 + fn payment_settled_false_only_for_explicit_unpaid() {
517 + // The async-method case: `checkout.session.completed` arrives with
518 + // "unpaid" and goods must NOT be delivered until settlement.
519 + assert!(!view_with_status(Some("unpaid")).payment_settled());
520 + }
521 +
522 + #[test]
523 + fn payment_settled_true_when_absent_preserves_legacy_behaviour() {
524 + // Older/edge events without the field must still finalize (synchronous
525 + // card sessions predating the field, and any event Stripe omits it on).
526 + assert!(view_with_status(None).payment_settled());
527 + assert!(!view_with_status(Some("something_new")).payment_settled());
528 + }
529 +
530 + #[test]
531 + fn payment_status_and_currency_deserialize_from_session_json() {
532 + let session: CheckoutSessionView = serde_json::from_value(json!({
533 + "id": "cs_1",
534 + "payment_status": "unpaid",
535 + "currency": "usd",
536 + })).unwrap();
537 + assert_eq!(session.payment_status.as_deref(), Some("unpaid"));
538 + assert_eq!(session.currency.as_deref(), Some("usd"));
539 + assert!(!session.payment_settled());
540 +
541 + // Absent fields default to None (settled).
542 + let bare: CheckoutSessionView = serde_json::from_value(json!({"id": "cs_2"})).unwrap();
543 + assert!(bare.payment_status.is_none());
544 + assert!(bare.currency.is_none());
545 + assert!(bare.payment_settled());
546 + }
547 +
476 548 // Subscription parses with current_period_* on items.data[0].
477 549 #[test]
478 550 fn subscription_parses_from_fixture_with_items_period() {
@@ -52,6 +52,66 @@ async fn escalate_if_orphaned_session(
52 52 Ok(())
53 53 }
54 54
55 + /// Defense-in-depth reconciliation of a completed checkout against Stripe's
56 + /// reported session totals. Our line items are server-built, so the credited
57 + /// total should equal Stripe's pre-tax subtotal and the session should be USD.
58 + /// A currency mismatch or an amount mismatch is logged loudly and escalated to
59 + /// WAM (fire-and-forget so it never runs inside an open DB transaction); the
60 + /// server-recorded amount stays authoritative either way. Shared by the
61 + /// purchase, cart, and guest completion handlers so the three can't drift.
62 + fn reconcile_checkout_amount(
63 + state: &AppState,
64 + session_id: &str,
65 + session: &crate::payments::CheckoutSessionView,
66 + credited_cents: i64,
67 + label: &str,
68 + ) {
69 + // Currency guard: a non-USD session makes the integer-cents subtotal
70 + // comparison meaningless and should never happen (sessions are built USD).
71 + if let Some(currency) = session.currency.as_deref()
72 + && !currency.eq_ignore_ascii_case("usd")
73 + {
74 + tracing::error!(
75 + session_id = %session_id, currency = %currency,
76 + "checkout session currency is not USD ({label}); integer-cents reconciliation skipped"
77 + );
78 + if let Some(wam) = state.wam.clone() {
79 + let session_id = session_id.to_string();
80 + let currency = currency.to_string();
81 + let label = label.to_string();
82 + tokio::spawn(async move {
83 + let body = format!(
84 + "Checkout session {session_id} ({label}) settled in {currency}, not USD. The \
85 + server credits its own USD amount, but investigate how a non-USD session was created."
86 + );
87 + wam.create_ticket("Non-USD checkout session", Some(&body), "high", "stripe-non-usd-session", Some(&session_id)).await;
88 + });
89 + }
90 + return;
91 + }
92 +
93 + if let Some(subtotal) = session.amount_subtotal
94 + && subtotal != credited_cents
95 + {
96 + tracing::error!(
97 + session_id = %session_id, credited_cents = %credited_cents, stripe_subtotal_cents = %subtotal,
98 + "checkout amount mismatch ({label}): credited amount differs from Stripe session subtotal"
99 + );
100 + if let Some(wam) = state.wam.clone() {
101 + let session_id = session_id.to_string();
102 + let label = label.to_string();
103 + tokio::spawn(async move {
104 + let body = format!(
105 + "Credited amount {credited_cents} cents != Stripe session subtotal {subtotal} cents \
106 + (session {session_id}, {label}). The server amount is authoritative; investigate a \
107 + price-edit / Stripe Tax / currency edge."
108 + );
109 + wam.create_ticket("Checkout amount mismatch", Some(&body), "high", "stripe-amount-mismatch", Some(&session_id)).await;
110 + });
111 + }
112 + }
113 + }
114 +
55 115 /// Handle checkout.session.completed for one-time purchases
56 116 #[tracing::instrument(skip_all, name = "stripe::handle_purchase_checkout")]
57 117 pub(super) async fn handle_purchase_checkout_completed(
@@ -90,34 +150,9 @@ pub(super) async fn handle_purchase_checkout_completed(
90 150 "transaction completed"
91 151 );
92 152
93 - // Defense-in-depth reconciliation: our line items are server-built,
94 - // so Stripe's pre-tax subtotal should equal the amount we credit. A
95 - // mismatch (a future Stripe Tax / price-edit / currency edge) is
96 - // logged loudly rather than silently trusted — we still credit the
97 - // server amount, which is authoritative.
98 - if let Some(subtotal) = session.amount_subtotal
99 - && subtotal != i64::from(tx.amount_cents)
100 - {
101 - tracing::error!(
102 - session_id = %session_id, credited_cents = %tx.amount_cents, stripe_subtotal_cents = %subtotal,
103 - "checkout amount mismatch: credited transaction amount differs from Stripe session subtotal"
104 - );
105 - // Escalate like every other money anomaly. Fire-and-forget on a
106 - // spawned task so the external WAM call doesn't run inside (and
107 - // hold the connection for) the open `db_tx`.
108 - if let Some(wam) = state.wam.clone() {
109 - let session_id = session_id.to_string();
110 - let credited = tx.amount_cents;
111 - tokio::spawn(async move {
112 - let body = format!(
113 - "Credited amount {credited} cents != Stripe session subtotal {subtotal} cents \
114 - (session {session_id}). The server amount is authoritative; investigate a \
115 - price-edit / Stripe Tax / currency edge."
116 - );
117 - wam.create_ticket("Checkout amount mismatch", Some(&body), "high", "stripe-amount-mismatch", Some(&session_id)).await;
118 - });
119 - }
120 - }
153 + // Defense-in-depth reconciliation (currency + subtotal) against the
154 + // server-authoritative credited amount.
155 + reconcile_checkout_amount(state, &session_id, session, i64::from(tx.amount_cents), "purchase");
121 156
122 157 // Increment denormalized sales_count (inside transaction)
123 158 if let Some(iid) = item_id {
@@ -235,31 +270,10 @@ pub(super) async fn handle_cart_checkout_completed(
235 270 count = completed_txs.len(), "cart transactions completed"
236 271 );
237 272
238 - // Defense-in-depth reconciliation: line items are server-built, so the sum
239 - // of the credited transactions should equal Stripe's pre-tax subtotal. A
240 - // mismatch (future Stripe Tax / price-edit / currency edge) is logged loudly
241 - // rather than silently trusted — the server amounts remain authoritative.
242 - if let Some(subtotal) = session.amount_subtotal {
243 - let credited: i64 = completed_txs.iter().map(|tx| i64::from(tx.amount_cents)).sum();
244 - if credited != subtotal {
245 - tracing::error!(
246 - session_id = %session_id, credited_cents = %credited, stripe_subtotal_cents = %subtotal,
247 - "cart checkout amount mismatch: sum of credited transactions differs from Stripe session subtotal"
248 - );
249 - // Escalate like every other money anomaly, off the open db_tx.
250 - if let Some(wam) = state.wam.clone() {
251 - let session_id = session_id.to_string();
252 - tokio::spawn(async move {
253 - let body = format!(
254 - "Sum of credited cart transactions {credited} cents != Stripe session subtotal \
255 - {subtotal} cents (session {session_id}). Server amounts are authoritative; \
256 - investigate a price-edit / Stripe Tax / currency edge."
257 - );
258 - wam.create_ticket("Cart checkout amount mismatch", Some(&body), "high", "stripe-amount-mismatch", Some(&session_id)).await;
259 - });
260 - }
261 - }
262 - }
273 + // Defense-in-depth reconciliation (currency + subtotal): the sum of the
274 + // credited transactions should equal Stripe's pre-tax subtotal.
275 + let cart_credited: i64 = completed_txs.iter().map(|tx| i64::from(tx.amount_cents)).sum();
276 + reconcile_checkout_amount(state, &session_id, session, cart_credited, "cart");
263 277
264 278 // Increment sales count for each item
265 279 for tx in &completed_txs {
@@ -684,30 +698,9 @@ pub(super) async fn handle_guest_checkout_completed(
684 698 "guest transaction completed"
685 699 );
686 700
687 - // Defense-in-depth reconciliation, mirroring the single-item and
688 - // cart paths: our line items are server-built, so Stripe's pre-tax
689 - // subtotal should equal what we credit. A mismatch is logged loudly
690 - // and escalated; the server amount stays authoritative.
691 - if let Some(subtotal) = session.amount_subtotal
692 - && subtotal != i64::from(tx.amount_cents)
693 - {
694 - tracing::error!(
695 - session_id = %session_id, credited_cents = %tx.amount_cents, stripe_subtotal_cents = %subtotal,
696 - "guest checkout amount mismatch: credited transaction amount differs from Stripe session subtotal"
697 - );
698 - if let Some(wam) = state.wam.clone() {
699 - let session_id = session_id.to_string();
700 - let credited = tx.amount_cents;
701 - tokio::spawn(async move {
702 - let body = format!(
703 - "Credited amount {credited} cents != Stripe session subtotal {subtotal} cents \
704 - (guest session {session_id}). The server amount is authoritative; investigate a \
705 - price-edit / Stripe Tax / currency edge."
706 - );
707 - wam.create_ticket("Guest checkout amount mismatch", Some(&body), "high", "stripe-amount-mismatch", Some(&session_id)).await;
708 - });
709 - }
710 - }
701 + // Defense-in-depth reconciliation (currency + subtotal), mirroring
702 + // the single-item and cart paths.
703 + reconcile_checkout_amount(state, &session_id, session, i64::from(tx.amount_cents), "guest");
711 704
712 705 // Increment sales count inside transaction
713 706 db::items::increment_sales_count(&mut *db_tx, meta.item_id)
@@ -124,27 +124,26 @@ pub(crate) async fn process_webhook_event(
124 124 data_object: serde_json::Value,
125 125 ) -> Result<()> {
126 126 match event_type {
127 - "checkout.session.completed" => {
127 + // Both events route through the same dispatcher. `completed` fires
128 + // immediately; for asynchronous payment methods (ACH/SEPA/Bacs) it
129 + // arrives with payment_status="unpaid" and the money-taking handlers
130 + // defer until `async_payment_succeeded` re-delivers the settled session.
131 + "checkout.session.completed" | "checkout.session.async_payment_succeeded" => {
128 132 let session: CheckoutSessionView = serde_json::from_value(data_object)
129 133 .map_err(|e| AppError::BadRequest(format!("Failed to parse CheckoutSession: {e}")))?;
130 - let meta = session.metadata.as_ref();
131 - if payments::is_fan_plus_checkout(meta) {
132 - checkout::handle_fan_plus_checkout_completed(state, &session, event_id).await?;
133 - } else if payments::is_creator_tier_checkout(meta) {
134 - checkout::handle_creator_tier_checkout_completed(state, &session, event_id).await?;
135 - } else if payments::is_synckit_app_sub_checkout(meta) {
136 - checkout::handle_synckit_app_sub_checkout_completed(state, &session, event_id).await?;
137 - } else if payments::is_tip_checkout(meta) {
138 - checkout::handle_tip_checkout_completed(state, &session, event_id).await?;
139 - } else if payments::is_subscription_checkout(meta) {
140 - checkout::handle_subscription_checkout_completed(state, &session, event_id).await?;
141 - } else if payments::is_guest_checkout(meta) {
142 - checkout::handle_guest_checkout_completed(state, &session, event_id).await?;
143 - } else if payments::is_cart_checkout(meta) {
144 - checkout::handle_cart_checkout_completed(state, &session, event_id).await?;
145 - } else {
146 - checkout::handle_purchase_checkout_completed(state, &session, event_id).await?;
147 - }
134 + dispatch_checkout_session(state, &session, event_id).await?;
135 + }
136 + // The buyer's async payment (ACH/SEPA/Bacs) never cleared. No funds were
137 + // captured, so there is nothing to deliver; the pending transaction (and
138 + // any reserved promo hold) is released by the stale-pending cleanup
139 + // sweeper. Logged for visibility rather than silently dropped.
140 + "checkout.session.async_payment_failed" => {
141 + let session: CheckoutSessionView = serde_json::from_value(data_object)
142 + .map_err(|e| AppError::BadRequest(format!("Failed to parse CheckoutSession: {e}")))?;
143 + tracing::warn!(
144 + session_id = %session.id,
145 + "checkout async payment failed; no funds captured, pending rows will be released by cleanup"
146 + );
148 147 }
149 148 "account.updated" => {
150 149 let account: AccountView = serde_json::from_value(data_object)
@@ -198,6 +197,59 @@ pub(crate) async fn process_webhook_event(
198 197 Ok(())
199 198 }
200 199
200 + /// Route a checkout session to its handler by metadata shape.
201 + ///
202 + /// Subscription-mode sessions (Fan+, creator tier, SyncKit app sub, project
203 + /// subscription) capture no funds at checkout — the subscription lifecycle bills
204 + /// separately — so they run unconditionally. One-time payment-mode sessions
205 + /// (tip, guest, cart, single purchase) capture funds now and are therefore
206 + /// gated on `payment_settled()`: an async method that reports `payment_status
207 + /// = "unpaid"` on `checkout.session.completed` is deferred until Stripe
208 + /// re-delivers the settled session via `checkout.session.async_payment_succeeded`.
209 + /// Without this gate, enabling any async payment method on a connected account
210 + /// would mint license keys and grant downloads before funds settle.
211 + async fn dispatch_checkout_session(
212 + state: &AppState,
213 + session: &CheckoutSessionView,
214 + event_id: &str,
215 + ) -> Result<()> {
216 + let meta = session.metadata.as_ref();
217 +
218 + // Subscription-mode: no funds captured at checkout, no settlement gate.
219 + if payments::is_fan_plus_checkout(meta) {
220 + return checkout::handle_fan_plus_checkout_completed(state, session, event_id).await;
221 + }
222 + if payments::is_creator_tier_checkout(meta) {
223 + return checkout::handle_creator_tier_checkout_completed(state, session, event_id).await;
224 + }
225 + if payments::is_synckit_app_sub_checkout(meta) {
226 + return checkout::handle_synckit_app_sub_checkout_completed(state, session, event_id).await;
227 + }
228 + if payments::is_subscription_checkout(meta) {
229 + return checkout::handle_subscription_checkout_completed(state, session, event_id).await;
230 + }
231 +
232 + // One-time payment-mode: funds captured now. Deliver only once settled.
233 + if !session.payment_settled() {
234 + tracing::info!(
235 + session_id = %session.id,
236 + payment_status = ?session.payment_status,
237 + "one-time checkout not yet settled (async payment); deferring finalize until async_payment_succeeded"
238 + );
239 + return Ok(());
240 + }
241 +
242 + if payments::is_tip_checkout(meta) {
243 + checkout::handle_tip_checkout_completed(state, session, event_id).await
244 + } else if payments::is_guest_checkout(meta) {
245 + checkout::handle_guest_checkout_completed(state, session, event_id).await
246 + } else if payments::is_cart_checkout(meta) {
247 + checkout::handle_cart_checkout_completed(state, session, event_id).await
248 + } else {
249 + checkout::handle_purchase_checkout_completed(state, session, event_id).await
250 + }
251 + }
252 +
201 253 /// Handle account.updated from the v2 thin event endpoint.
202 254 pub(in crate::routes::stripe) async fn handle_account_updated_from_v2(
203 255 state: &AppState,
@@ -54,8 +54,20 @@ async fn post_webhook_json(
54 54 event_type: &str,
55 55 object: serde_json::Value,
56 56 ) -> crate::harness::client::TestResponse {
57 + post_webhook_json_with_event_id(h, "evt_mock_001", event_type, object).await
58 + }
59 +
60 + /// Post a JSON webhook event with an explicit event id. Distinct ids are
61 + /// required when a single test delivers more than one event, since the webhook
62 + /// dedup short-circuits a repeated id.
63 + async fn post_webhook_json_with_event_id(
64 + h: &mut TestHarness,
65 + event_id: &str,
66 + event_type: &str,
67 + object: serde_json::Value,
68 + ) -> crate::harness::client::TestResponse {
57 69 let payload = serde_json::json!({
58 - "id": "evt_mock_001",
70 + "id": event_id,
59 71 "type": event_type,
60 72 "data": {"object": object},
61 73 })
@@ -153,6 +165,99 @@ async fn checkout_creates_session_and_webhook_completes_purchase() {
153 165 assert_eq!(sales, 1);
154 166 }
155 167
168 + /// An asynchronous payment method (ACH/SEPA/Bacs) delivers
169 + /// `checkout.session.completed` with `payment_status="unpaid"` BEFORE funds
170 + /// settle. The one-time purchase must NOT finalize then — goods must not ship
171 + /// against unpaid funds — and must finalize when Stripe later re-delivers the
172 + /// settled session via `checkout.session.async_payment_succeeded`.
173 + #[tokio::test]
174 + async fn async_unpaid_checkout_defers_until_payment_succeeds() {
175 + let mut h = TestHarness::with_mocks().await;
176 + let (seller_id, _project_id, item_id) = setup_paid_item(&mut h, 777).await;
177 +
178 + let buyer_id = h.signup("asyncbuyer", "asyncbuyer@test.com", "pass1234").await;
179 +
180 + // Buyer initiates checkout, creating the pending transaction.
181 + h.client.post_form(
182 + &format!("/stripe/checkout/{}", item_id),
183 + "share_contact=false",
184 + ).await;
185 +
186 + let pending_tx: Option<(String,)> = sqlx::query_as(
187 + "SELECT stripe_checkout_session_id FROM transactions WHERE buyer_id = $1 AND status = 'pending'",
188 + )
189 + .bind(buyer_id)
190 + .fetch_optional(&h.db)
191 + .await
192 + .unwrap();
193 + let session_id = pending_tx.expect("checkout created a pending transaction").0;
194 +
195 + let mut meta = HashMap::new();
196 + meta.insert("buyer_id".to_string(), buyer_id.to_string());
197 + meta.insert("seller_id".to_string(), seller_id.to_string());
198 + meta.insert("item_id".to_string(), item_id.clone());
199 +
200 + // 1. completed + unpaid → deferred (still pending, no sale counted).
201 + let unpaid = serde_json::json!({
202 + "id": session_id,
203 + "object": "checkout_session",
204 + "mode": "payment",
205 + "metadata": meta,
206 + "payment_intent": "pi_async_001",
207 + "payment_status": "unpaid",
208 + "currency": "usd",
209 + });
210 + let resp = post_webhook_json_with_event_id(&mut h, "evt_async_unpaid", "checkout.session.completed", unpaid).await;
211 + assert_eq!(resp.status.as_u16(), 200, "webhook should ack even when deferring: {}", resp.text);
212 +
213 + let status: String = sqlx::query_scalar(
214 + "SELECT status FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid",
215 + )
216 + .bind(buyer_id)
217 + .bind(&item_id)
218 + .fetch_one(&h.db)
219 + .await
220 + .unwrap();
221 + assert_eq!(status, "pending", "unpaid async session must NOT finalize the purchase");
222 +
223 + let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid")
224 + .bind(&item_id)
225 + .fetch_one(&h.db)
226 + .await
227 + .unwrap();
228 + assert_eq!(sales, 0, "no sale should be counted before settlement");
229 +
230 + // 2. async_payment_succeeded (funds settled) → finalize.
231 + let settled = serde_json::json!({
232 + "id": session_id,
233 + "object": "checkout_session",
234 + "mode": "payment",
235 + "metadata": meta,
236 + "payment_intent": "pi_async_001",
237 + "payment_status": "paid",
238 + "currency": "usd",
239 + });
240 + let resp = post_webhook_json_with_event_id(&mut h, "evt_async_paid", "checkout.session.async_payment_succeeded", settled).await;
241 + assert_eq!(resp.status.as_u16(), 200, "settlement webhook failed: {}", resp.text);
242 +
243 + let status: String = sqlx::query_scalar(
244 + "SELECT status FROM transactions WHERE buyer_id = $1 AND item_id = $2::uuid",
245 + )
246 + .bind(buyer_id)
247 + .bind(&item_id)
248 + .fetch_one(&h.db)
249 + .await
250 + .unwrap();
251 + assert_eq!(status, "completed", "settled async session must finalize the purchase");
252 +
253 + let sales: i32 = sqlx::query_scalar("SELECT sales_count FROM items WHERE id = $1::uuid")
254 + .bind(&item_id)
255 + .fetch_one(&h.db)
256 + .await
257 + .unwrap();
258 + assert_eq!(sales, 1, "sale should be counted once settled");
259 + }
260 +
156 261 // ---------------------------------------------------------------------------
157 262 // Email assertions
158 263 // ---------------------------------------------------------------------------