$ctx expects: email, name, optional customer_ref */ public function createMandate(array $ctx): array { // 1. Ensure a Stripe Customer. $customerRef = (string) ($ctx['customer_ref'] ?? ''); if ($customerRef === '') { $cust = $this->client->post('customers', [ 'email' => (string) ($ctx['email'] ?? ''), 'name' => (string) ($ctx['name'] ?? ''), ]); if (!$cust['ok']) { return ['ok' => false, 'error' => $cust['error'] ?? 'customer-failed']; } $customerRef = (string) $cust['data']['id']; } // 2. SetupIntent for a reusable SEPA mandate. Front-end confirms with the IBAN. $params = [ 'customer' => $customerRef, 'payment_method_types' => ['sepa_debit'], 'usage' => 'off_session', ]; foreach ((array) ($ctx['metadata'] ?? []) as $k => $v) { $params['metadata'][$k] = (string) $v; } $si = $this->client->post('setup_intents', $params); if (!$si['ok']) { return ['ok' => false, 'error' => $si['error'] ?? 'setupintent-failed']; } return [ 'ok' => true, 'mandate_ref' => $customerRef, // PM id is captured on setup_intent.succeeded webhook 'client_secret' => (string) ($si['data']['client_secret'] ?? ''), ]; } /** * Register a reusable SEPA mandate from a PaymentMethod created client-side * (stripe.createPaymentMethod on the checkout, BEFORE the order submits — no * redirect race). Creates the Customer, attaches the PM, and confirms a * server-side SetupIntent (off_session) with mandate acceptance to make the * mandate reusable for renewals. * * @param array{email:string,name:string,pm_id:string,metadata?:array,ip?:string,ua?:string} $ctx * @return array{ok:bool, customer_ref?:string, error?:string} */ public function registerMandate(array $ctx): array { $pm = (string) ($ctx['pm_id'] ?? ''); if ($pm === '') { return ['ok' => false, 'error' => 'no-payment-method']; } // 1. Customer. $cust = $this->client->post('customers', [ 'email' => (string) ($ctx['email'] ?? ''), 'name' => (string) ($ctx['name'] ?? ''), 'payment_method' => $pm, ]); if (!$cust['ok']) { return ['ok' => false, 'error' => $cust['error'] ?? 'customer-failed']; } $customer = (string) $cust['data']['id']; // 2. Confirm a SetupIntent to register the mandate for off_session reuse. $params = [ 'customer' => $customer, 'payment_method' => $pm, 'payment_method_types' => ['sepa_debit'], 'usage' => 'off_session', 'confirm' => 'true', 'mandate_data' => [ 'customer_acceptance' => [ 'type' => 'online', 'online' => [ 'ip_address' => (string) ($ctx['ip'] ?? '0.0.0.0'), 'user_agent' => (string) ($ctx['ua'] ?? 'unknown'), ], ], ], ]; foreach ((array) ($ctx['metadata'] ?? []) as $k => $v) { $params['metadata'][$k] = (string) $v; } $si = $this->client->post('setup_intents', $params); if (!$si['ok']) { return ['ok' => false, 'error' => $si['error'] ?? 'setupintent-failed']; } // SEPA SetupIntents settle to 'succeeded' quickly for the mandate itself. return ['ok' => true, 'customer_ref' => $customer . '|' . $pm]; } /** * @param array $subscription */ public function chargeOffSession(array $subscription): array { [$customer, $pm] = $this->splitRef((string) ($subscription['gateway_ref'] ?? '')); if ($customer === '' || $pm === '') { return ['status' => 'failed', 'reason' => 'no-mandate']; } $amountCents = (int) round(((float) $subscription['amount']) * 100); $idem = 'renew_' . ($subscription['id'] ?? '0') . '_' . ($subscription['current_period_end'] ?? ''); $pi = $this->client->post('payment_intents', [ 'amount' => $amountCents, 'currency' => strtolower((string) ($subscription['currency'] ?? 'eur')), 'customer' => $customer, 'payment_method' => $pm, 'payment_method_types' => ['sepa_debit'], 'off_session' => 'true', 'confirm' => 'true', 'metadata' => ['abovault_subscription_id' => (string) ($subscription['id'] ?? '')], ], $idem); if (!$pi['ok']) { return ['status' => 'failed', 'reason' => $pi['error'] ?? 'charge-error']; } $status = (string) ($pi['data']['status'] ?? ''); $ref = (string) ($pi['data']['id'] ?? ''); // SEPA: 'processing' is the normal, expected outcome. Webhook confirms later. return match ($status) { 'succeeded' => ['status' => 'succeeded', 'reference' => $ref], 'processing', 'requires_action' => ['status' => 'processing', 'reference' => $ref], default => ['status' => 'failed', 'reference' => $ref, 'reason' => $status], }; } public function refund(string $chargeRef, ?int $amountCents = null): array { $params = ['payment_intent' => $chargeRef]; if ($amountCents !== null) { $params['amount'] = $amountCents; } $r = $this->client->post('refunds', $params); return $r['ok'] ? ['ok' => true, 'reference' => (string) ($r['data']['id'] ?? '')] : ['ok' => false, 'error' => $r['error'] ?? 'refund-failed']; } /** @return array{0:string,1:string} [customer, paymentMethod] */ private function splitRef(string $ref): array { $parts = explode('|', $ref, 2); return [$parts[0] ?? '', $parts[1] ?? '']; } }