eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// πŸ€– Managed payments (docs/managed-payments.md, phase 1): the payout-model
4// provider for paid access levels. The platform is merchant of record β€”
5// charges run on the PLATFORM Stripe account and each invoice transfers 89%
6// to the space's Express account via destination charges, so the admin's cut
7// is structural (transfer_data), never reconciled. Card processing fees land
8// on the platform side by construction: "11% all-in" is how the charge is
9// shaped, not a promise kept by refunds.
10//
11// This file is deliberately DARK until phase 2: nothing writes the
12// stripe_express_account config key yet (the Express onboarding flow does),
13// and without it Configured() is false, so the feature stays dormant even
14// with platform creds deployed. stripeConnect (BYO, subscriptions.go) is
15// untouched and remains the live path.
16
17import (
18 "context"
19 "encoding/json"
20 "errors"
21 "fmt"
22 "io"
23 "net/http"
24 "net/url"
25 "strconv"
26 "strings"
27 "time"
28)
29
30// The platform's managed commission is pay-what-you-want within a range
31// (docs/managed-payments.md): the admin picks 5.5–11%, suggested 11, and
32// processing fees come out of ours, never theirs. The choice lives in the
33// space config (managed_commission_pct, written by the phase-2 UI); the
34// transfer percent is its complement. The floor is roughly our cost line β€”
35// small tickets on international cards can dip under it, priced in.
36const (
37 managedKeepDefaultPct = 11.0
38 managedKeepMinPct = 5.5
39)
40
41// managedKeepPct is this space's chosen commission: clamp(choice, 5.5, 11).
42// Unset or unparseable β†’ the suggested 11 β€” never a silent freebie.
43func (p *stripePlatform) managedKeepPct() float64 {
44 keep := managedKeepDefaultPct
45 if v, _, _ := p.db.getConfig("managed_commission_pct"); v != "" {
46 if f, err := strconv.ParseFloat(v, 64); err == nil {
47 keep = min(max(f, managedKeepMinPct), managedKeepDefaultPct)
48 }
49 }
50 return keep
51}
52
53// managedTransferPercent is the commission's complement as Stripe's
54// amount_percent: "89"…"94.5".
55func (p *stripePlatform) managedTransferPercent() string {
56 return strconv.FormatFloat(100-p.managedKeepPct(), 'f', -1, 64)
57}
58
59// onceTransferMinor is the admin's share of a lifetime purchase in minor
60// units β€” payment mode has no amount_percent, so the same commission is
61// applied as a fixed transfer, rounded in the admin's favour.
62func (p *stripePlatform) onceTransferMinor(amountMinor int) int {
63 fee := int(float64(amountMinor) * p.managedKeepPct() / 100)
64 return amountMinor - fee
65}
66
67// stripePlatform is the managed-payments provider: same PayProvider seam as
68// stripeConnect, but every call runs on the platform account (no
69// Stripe-Account header) and checkout routes 89% to the space's Express
70// account. The Express account id lives in the space config, set by the
71// phase-2 onboarding flow.
72type stripePlatform struct {
73 db *DB
74 key string // platform secret key
75 webhookSecret string
76 apiBase string
77 client *http.Client
78}
79
80// expressAccount is the space's Stripe Express account id ("acct_…"), where
81// the 89% lands. Empty β†’ the space can't sell on managed payments yet.
82func (p *stripePlatform) expressAccount() string {
83 v, _, _ := p.db.getConfig("stripe_express_account")
84 return v
85}
86
87func (p *stripePlatform) Configured() bool { return p.expressAccount() != "" }
88
89// post form-encodes to the Stripe API on the platform account.
90func (p *stripePlatform) post(ctx context.Context, path string, form url.Values) (map[string]any, error) {
91 if p.expressAccount() == "" {
92 return nil, errBillingOff
93 }
94 req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+path, strings.NewReader(form.Encode()))
95 if err != nil {
96 return nil, err
97 }
98 req.Header.Set("Authorization", "Bearer "+p.key)
99 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
100 resp, err := p.client.Do(req)
101 if err != nil {
102 return nil, err
103 }
104 defer resp.Body.Close()
105 var body map[string]any
106 if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
107 return nil, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode)
108 }
109 if resp.StatusCode != http.StatusOK {
110 msg := ""
111 if e, ok := body["error"].(map[string]any); ok {
112 msg, _ = e["message"].(string)
113 }
114 return nil, fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, msg)
115 }
116 return body, nil
117}
118
119// get reads a Stripe object on the platform account.
120func (p *stripePlatform) get(ctx context.Context, path string) (map[string]any, error) {
121 if p.expressAccount() == "" {
122 return nil, errBillingOff
123 }
124 req, err := http.NewRequestWithContext(ctx, "GET", p.apiBase+path, nil)
125 if err != nil {
126 return nil, err
127 }
128 req.Header.Set("Authorization", "Bearer "+p.key)
129 resp, err := p.client.Do(req)
130 if err != nil {
131 return nil, err
132 }
133 defer resp.Body.Close()
134 var body map[string]any
135 if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
136 return nil, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode)
137 }
138 if resp.StatusCode != http.StatusOK {
139 return nil, fmt.Errorf("stripe: HTTP %d", resp.StatusCode)
140 }
141 return body, nil
142}
143
144func (p *stripePlatform) Checkout(ctx context.Context, r CheckoutReq) (string, error) {
145 acct := p.expressAccount()
146 form := url.Values{}
147 form.Set("line_items[0][price]", r.PriceRef)
148 form.Set("line_items[0][quantity]", "1")
149 form.Set("success_url", r.SuccessURL)
150 form.Set("cancel_url", r.CancelURL)
151 // Routing metadata, plus the space binding: platform-account webhooks
152 // carry every managed space's events, so each instance stamps its own
153 // Express account id and ParseWebhook drops the rest.
154 form.Set("metadata[user_ref]", r.UserRef)
155 form.Set("metadata[tier_ref]", r.TierID)
156 form.Set("metadata[space_ref]", acct)
157 if r.Once {
158 // Lifetime purchase: one payment, structurally split like the
159 // subscription case β€” but payment mode has no amount_percent, so the
160 // admin's share is a fixed amount from the same chosen commission.
161 // The intent carries the routing metadata too (refund/chargeback
162 // events reference the intent, not the session).
163 form.Set("mode", "payment")
164 form.Set("payment_intent_data[transfer_data][destination]", acct)
165 form.Set("payment_intent_data[transfer_data][amount]", strconv.Itoa(p.onceTransferMinor(r.AmountMinor)))
166 form.Set("payment_intent_data[metadata][user_ref]", r.UserRef)
167 form.Set("payment_intent_data[metadata][tier_ref]", r.TierID)
168 form.Set("payment_intent_data[metadata][space_ref]", acct)
169 } else {
170 form.Set("mode", "subscription")
171 // The structural split: the admin's share of every invoice transfers
172 // to the space's Express account; the platform's points (and Stripe's
173 // fees, out of them) stay behind. No application fee β€” the transfer
174 // IS the split.
175 form.Set("subscription_data[transfer_data][destination]", acct)
176 form.Set("subscription_data[transfer_data][amount_percent]", p.managedTransferPercent())
177 form.Set("subscription_data[metadata][user_ref]", r.UserRef)
178 form.Set("subscription_data[metadata][tier_ref]", r.TierID)
179 form.Set("subscription_data[metadata][space_ref]", acct)
180 }
181 body, err := p.post(ctx, "/v1/checkout/sessions", form)
182 if err != nil {
183 return "", err
184 }
185 u, _ := body["url"].(string)
186 if u == "" {
187 return "", fmt.Errorf("stripe: session created but no url")
188 }
189 return u, nil
190}
191
192func (p *stripePlatform) Cancel(ctx context.Context, extSub string) error {
193 // Cancel at period end, exactly like the BYO provider: access runs to the
194 // end of the paid period, no further charge.
195 form := url.Values{}
196 form.Set("cancel_at_period_end", "true")
197 _, err := p.post(ctx, "/v1/subscriptions/"+url.PathEscape(extSub), form)
198 return err
199}
200
201func (p *stripePlatform) Resume(ctx context.Context, extSub string) error {
202 form := url.Values{}
203 form.Set("cancel_at_period_end", "false")
204 _, err := p.post(ctx, "/v1/subscriptions/"+url.PathEscape(extSub), form)
205 return err
206}
207
208func (p *stripePlatform) SwitchLevel(ctx context.Context, extSub, newPriceRef, newTierRef string) error {
209 sub, err := p.get(ctx, "/v1/subscriptions/"+url.PathEscape(extSub))
210 if err != nil {
211 return err
212 }
213 itemID := ""
214 if items, ok := sub["items"].(map[string]any); ok {
215 if data, ok := items["data"].([]any); ok && len(data) > 0 {
216 if it, ok := data[0].(map[string]any); ok {
217 itemID, _ = it["id"].(string)
218 }
219 }
220 }
221 if itemID == "" {
222 return fmt.Errorf("stripe: subscription %s has no item to switch", extSub)
223 }
224 form := url.Values{}
225 form.Set("items[0][id]", itemID)
226 form.Set("items[0][price]", newPriceRef)
227 form.Set("proration_behavior", "create_prorations")
228 form.Set("metadata[tier_ref]", newTierRef)
229 _, err = p.post(ctx, "/v1/subscriptions/"+url.PathEscape(extSub), form)
230 return err
231}
232
233func (p *stripePlatform) SubState(ctx context.Context, extSub string) (SubEvent, bool, error) {
234 if p.expressAccount() == "" {
235 return SubEvent{}, false, errBillingOff
236 }
237 req, err := http.NewRequestWithContext(ctx, "GET", p.apiBase+"/v1/subscriptions/"+url.PathEscape(extSub), nil)
238 if err != nil {
239 return SubEvent{}, false, err
240 }
241 req.Header.Set("Authorization", "Bearer "+p.key)
242 resp, err := p.client.Do(req)
243 if err != nil {
244 return SubEvent{}, false, err
245 }
246 defer resp.Body.Close()
247 if resp.StatusCode == http.StatusNotFound {
248 return SubEvent{}, false, nil
249 }
250 var o subObject
251 if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&o); err != nil {
252 return SubEvent{}, false, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode)
253 }
254 if resp.StatusCode != http.StatusOK {
255 return SubEvent{}, false, fmt.Errorf("stripe: HTTP %d", resp.StatusCode)
256 }
257 return o.subEvent(), true, nil
258}
259
260func (p *stripePlatform) CreatePrice(ctx context.Context, name string, cents int, currency, interval string) (string, error) {
261 // Managed levels' Prices live on the PLATFORM account (we are the
262 // merchant); the admin never needs a Stripe dashboard. Inline product,
263 // like the BYO path.
264 form := url.Values{}
265 form.Set("product_data[name]", name)
266 form.Set("unit_amount", fmt.Sprintf("%d", cents))
267 form.Set("currency", currency)
268 form.Set("recurring[interval]", interval)
269 body, err := p.post(ctx, "/v1/prices", form)
270 if err != nil {
271 return "", err
272 }
273 id, _ := body["id"].(string)
274 if id == "" {
275 return "", fmt.Errorf("stripe: price created but no id")
276 }
277 return id, nil
278}
279
280func (p *stripePlatform) PortalURL(ctx context.Context, extCustomer, returnURL string) (string, error) {
281 form := url.Values{}
282 form.Set("customer", extCustomer)
283 form.Set("return_url", returnURL)
284 body, err := p.post(ctx, "/v1/billing_portal/sessions", form)
285 if err != nil {
286 return "", err
287 }
288 u, _ := body["url"].(string)
289 return u, nil
290}
291
292// ParseWebhook verifies a platform-account webhook and normalizes it. The
293// platform endpoint receives EVERY managed space's events (they all live on
294// one Stripe account), so a valid signature is not proof the event is ours β€”
295// the space binding is the space_ref metadata stamped at checkout, matched
296// against THIS space's Express account. Foreign or unstamped events are
297// dropped (acknowledged, never retried) β€” same posture as the BYO provider's
298// connected-account binding, for the same forgery reason.
299func (p *stripePlatform) ParseWebhook(sig string, body []byte) ([]SubEvent, error) {
300 if !verifyStripeSignature(body, sig, p.webhookSecret, time.Now()) {
301 return nil, errors.New("webhook signature verification failed")
302 }
303 var ev struct {
304 Type string `json:"type"`
305 Created int64 `json:"created"`
306 Data struct {
307 Object json.RawMessage `json:"object"`
308 } `json:"data"`
309 }
310 if err := json.Unmarshal(body, &ev); err != nil {
311 return nil, err
312 }
313 acct := p.expressAccount()
314 if acct == "" {
315 return nil, nil
316 }
317 switch {
318 case ev.Type == "checkout.session.completed":
319 var o struct {
320 Mode string `json:"mode"`
321 Customer string `json:"customer"`
322 Subscription string `json:"subscription"`
323 PaymentIntent string `json:"payment_intent"`
324 Metadata map[string]string `json:"metadata"`
325 }
326 if err := json.Unmarshal(ev.Data.Object, &o); err != nil {
327 return nil, nil
328 }
329 if o.Metadata["space_ref"] != acct {
330 return nil, nil // another space's sale β€” or donate/enterprise, which never stamp one
331 }
332 // Payment mode + our binding = a lifetime purchase (see the BYO
333 // provider for the shape's rationale).
334 if o.Mode == "payment" {
335 if o.Metadata["tier_ref"] == "" || o.PaymentIntent == "" {
336 return nil, nil
337 }
338 return []SubEvent{{
339 ExtSub: o.PaymentIntent, ExtCustomer: o.Customer,
340 UserRef: o.Metadata["user_ref"], TierRef: o.Metadata["tier_ref"],
341 Status: "active", Created: ev.Created, AllowCreate: true, Lifetime: true,
342 }}, nil
343 }
344 if o.Mode != "subscription" {
345 return nil, nil
346 }
347 return []SubEvent{{
348 ExtSub: o.Subscription, ExtCustomer: o.Customer,
349 UserRef: o.Metadata["user_ref"], TierRef: o.Metadata["tier_ref"],
350 Status: "active", Created: ev.Created, AllowCreate: true, Checkout: true,
351 }}, nil
352 case ev.Type == "charge.refunded" || ev.Type == "charge.dispute.created":
353 // The refund/chargeback that un-buys a lifetime purchase. Bind on the
354 // stamped space_ref before the shared normalizer, same as above.
355 var bind struct {
356 Metadata map[string]string `json:"metadata"`
357 }
358 if err := json.Unmarshal(ev.Data.Object, &bind); err != nil || bind.Metadata["space_ref"] != acct {
359 return nil, nil
360 }
361 return lifetimeRevokeEvent(ev.Data.Object, ev.Created)
362 case strings.HasPrefix(ev.Type, "customer.subscription."):
363 var o subObject
364 if err := json.Unmarshal(ev.Data.Object, &o); err != nil {
365 return nil, err
366 }
367 if o.Metadata["space_ref"] != acct {
368 return nil, nil
369 }
370 se := o.subEvent()
371 se.Deleted = ev.Type == "customer.subscription.deleted"
372 se.Created = ev.Created
373 se.AllowCreate = ev.Type == "customer.subscription.created"
374 return []SubEvent{se}, nil
375 }
376 return nil, nil
377}
378