Go · 20725 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package orgs
4
5 import (
6 "errors"
7 "fmt"
8 "net/http"
9 "net/url"
10 "strings"
11 "time"
12
13 "github.com/jackc/pgx/v5"
14 "github.com/jackc/pgx/v5/pgtype"
15
16 orgbilling "github.com/tenseleyFlow/shithub/internal/billing"
17 billingdb "github.com/tenseleyFlow/shithub/internal/billing/sqlc"
18 "github.com/tenseleyFlow/shithub/internal/billing/stripebilling"
19 "github.com/tenseleyFlow/shithub/internal/entitlements"
20 orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
21 "github.com/tenseleyFlow/shithub/internal/web/middleware"
22 )
23
24 type billingSummaryItem struct {
25 Label string
26 Value string
27 Detail string
28 }
29
30 type billingInvoiceView struct {
31 Number string
32 StatusLabel string
33 StatusClass string
34 AmountLabel string
35 PeriodLabel string
36 DueLabel string
37 HostedInvoiceURL string
38 InvoicePDFURL string
39 }
40
41 type billingSeatBreakdown struct {
42 ActiveMembers int
43 BillableSeats int64
44 PendingInvites int
45 SnapshotLabel string
46 }
47
48 type billingPrivateCollaborationBreakdown struct {
49 Count int64
50 LimitLabel string
51 Detail string
52 }
53
54 type billingAlert struct {
55 Class string
56 Message string
57 ActionText string
58 ActionHref string
59 }
60
61 type billingDebugView struct {
62 StripeCustomerID string
63 StripeSubscriptionID string
64 StripeSubscriptionItemID string
65 LastWebhookEventID string
66 LastWebhookEventType string
67 LastWebhookStatus string
68 LastWebhookReceivedAt string
69 LastWebhookProcessedAt string
70 LastWebhookAttempts int32
71 LastWebhookError string
72 }
73
74 func (h *Handlers) settingsBilling(w http.ResponseWriter, r *http.Request) {
75 org, ok := h.loadOrgSettingsOwner(w, r)
76 if !ok {
77 return
78 }
79 h.renderSettingsBilling(w, r, org, "", billingNotice(r.URL.Query().Get("notice")))
80 }
81
82 func (h *Handlers) billingCheckout(w http.ResponseWriter, r *http.Request) {
83 org, ok := h.loadOrgSettingsOwner(w, r)
84 if !ok {
85 return
86 }
87 if !h.billingConfigured() {
88 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
89 return
90 }
91 sessionURL, err := h.startBillingCheckout(r, org)
92 if err != nil {
93 h.d.Logger.ErrorContext(r.Context(), "org billing: create checkout", "org_id", org.ID, "error", err)
94 h.renderSettingsBilling(w, r, org, "Could not start checkout right now.", "")
95 return
96 }
97 http.Redirect(w, r, sessionURL, http.StatusSeeOther)
98 }
99
100 func (h *Handlers) startBillingCheckout(r *http.Request, org orgsdb.Org) (string, error) {
101 state, err := orgbilling.GetOrgBillingState(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID)
102 if err != nil {
103 return "", fmt.Errorf("load billing state: %w", err)
104 }
105 state, err = h.ensureStripeCustomer(r, org, state)
106 if err != nil {
107 return "", fmt.Errorf("ensure stripe customer: %w", err)
108 }
109 seats, err := orgbilling.CountBillableOrgMembers(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID)
110 if err != nil {
111 return "", fmt.Errorf("count billable seats: %w", err)
112 }
113 session, err := h.d.Stripe.CreateCheckoutSession(r.Context(), stripebilling.CheckoutInput{
114 OrgID: org.ID,
115 OrgSlug: org.Slug,
116 CustomerID: state.StripeCustomerID.String,
117 SeatCount: int64(seats),
118 SuccessURL: h.billingReturnURL(org.Slug, h.d.StripeSuccessURL, "/organizations/"+org.Slug+"/billing/success"),
119 CancelURL: h.billingReturnURL(org.Slug, h.d.StripeCancelURL, "/organizations/"+org.Slug+"/billing/cancel"),
120 })
121 if err != nil {
122 return "", fmt.Errorf("create stripe checkout session: %w", err)
123 }
124 return session.URL, nil
125 }
126
127 func (h *Handlers) billingPortal(w http.ResponseWriter, r *http.Request) {
128 org, ok := h.loadOrgSettingsOwner(w, r)
129 if !ok {
130 return
131 }
132 if !h.billingConfigured() {
133 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
134 return
135 }
136 state, err := orgbilling.GetOrgBillingState(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID)
137 if err != nil {
138 h.d.Logger.ErrorContext(r.Context(), "org billing: load state for portal", "org_id", org.ID, "error", err)
139 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
140 return
141 }
142 if !state.StripeCustomerID.Valid || strings.TrimSpace(state.StripeCustomerID.String) == "" {
143 h.renderSettingsBilling(w, r, org, "Billing portal is unavailable until this organization has a Stripe customer record.", "")
144 return
145 }
146 session, err := h.d.Stripe.CreatePortalSession(r.Context(), stripebilling.PortalInput{
147 CustomerID: state.StripeCustomerID.String,
148 ReturnURL: h.billingReturnURL(org.Slug, h.d.StripePortalReturnURL, orgBillingSettingsPath(org.Slug)),
149 })
150 if err != nil {
151 h.d.Logger.ErrorContext(r.Context(), "org billing: create portal session", "org_id", org.ID, "error", err)
152 h.renderSettingsBilling(w, r, org, "Could not open the Stripe billing portal right now.", "")
153 return
154 }
155 http.Redirect(w, r, session.URL, http.StatusSeeOther)
156 }
157
158 func (h *Handlers) billingSuccess(w http.ResponseWriter, r *http.Request) {
159 org, ok := h.loadOrgSettingsOwner(w, r)
160 if !ok {
161 return
162 }
163 h.renderBillingResult(w, r, org, billingResultSuccess)
164 }
165
166 func (h *Handlers) billingCancel(w http.ResponseWriter, r *http.Request) {
167 org, ok := h.loadOrgSettingsOwner(w, r)
168 if !ok {
169 return
170 }
171 h.renderBillingResult(w, r, org, billingResultCanceled)
172 }
173
174 const (
175 billingResultSuccess = "success"
176 billingResultCanceled = "canceled"
177 )
178
179 func (h *Handlers) renderBillingResult(w http.ResponseWriter, r *http.Request, org orgsdb.Org, result string) {
180 heading := "Checkout complete"
181 message := "Stripe accepted the checkout session. Team activation finishes after shithub receives and processes the signed Stripe webhook."
182 if result == billingResultCanceled {
183 heading = "Checkout canceled"
184 message = "No Team subscription was activated. The organization stays on Free until checkout is completed."
185 }
186 _ = h.d.Render.RenderPage(w, r, "orgs/billing_result", map[string]any{
187 "Title": heading,
188 "CSRFToken": middleware.CSRFTokenForRequest(r),
189 "Org": org,
190 "AvatarURL": "/avatars/" + url.PathEscape(org.Slug),
191 "Result": result,
192 "Heading": heading,
193 "Message": message,
194 "BillingPath": orgBillingSettingsPath(org.Slug),
195 })
196 }
197
198 func (h *Handlers) renderSettingsBilling(w http.ResponseWriter, r *http.Request, org orgsdb.Org, errMsg, notice string) {
199 state, err := orgbilling.GetOrgBillingState(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID)
200 if err != nil {
201 h.d.Logger.ErrorContext(r.Context(), "org billing: load state", "org_id", org.ID, "error", err)
202 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
203 return
204 }
205 memberCount, err := orgbilling.CountBillableOrgMembers(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID)
206 if err != nil {
207 h.d.Logger.WarnContext(r.Context(), "org billing: count members", "org_id", org.ID, "error", err)
208 memberCount = int(state.BillableSeats)
209 }
210 pendingInviteCount, err := orgbilling.CountPendingOrgInvitations(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID)
211 if err != nil {
212 h.d.Logger.WarnContext(r.Context(), "org billing: count pending invitations", "org_id", org.ID, "error", err)
213 }
214 privateCollab := h.billingPrivateCollaborationBreakdown(r, org.ID)
215 invoices, err := orgbilling.ListInvoicesForOrg(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID, 10)
216 if err != nil {
217 h.d.Logger.WarnContext(r.Context(), "org billing: list invoices", "org_id", org.ID, "error", err)
218 invoices = nil
219 }
220 viewer := middleware.CurrentUserFromContext(r.Context())
221 debug := billingDebugView{}
222 if viewer.IsSiteAdmin {
223 debug = h.billingDebugView(r, state)
224 }
225 _ = h.d.Render.RenderPage(w, r, "orgs/settings_billing", map[string]any{
226 "Title": org.Slug + " - billing and plans",
227 "CSRFToken": middleware.CSRFTokenForRequest(r),
228 "Org": org,
229 "AvatarURL": "/avatars/" + url.PathEscape(org.Slug),
230 "ActiveOrgNav": "settings",
231 "OrgSettingsActive": "billing",
232 "BillingEnabled": h.d.BillingEnabled,
233 "Error": errMsg,
234 "Notice": notice,
235 "BillingAlert": billingAlertForState(state, org.Slug),
236 "Summary": billingSummary(state, memberCount),
237 "Seats": billingSeatBreakdown{ActiveMembers: memberCount, BillableSeats: int64(state.BillableSeats), PendingInvites: pendingInviteCount, SnapshotLabel: billingSeatDetail(state)},
238 "PrivateCollaboration": privateCollab,
239 "CanStartCheckout": h.billingConfigured(),
240 "CanManageSubscription": h.billingConfigured() && state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "",
241 "GracePeriodLabel": formatGracePeriod(h.d.BillingGracePeriod),
242 "Invoices": billingInvoiceViews(invoices),
243 "IsSiteAdmin": viewer.IsSiteAdmin,
244 "Debug": debug,
245 })
246 }
247
248 func (h *Handlers) billingPrivateCollaborationBreakdown(r *http.Request, orgID int64) billingPrivateCollaborationBreakdown {
249 usage, err := entitlements.PrivateCollaborationUsageForOrg(r.Context(), entitlements.Deps{Pool: h.d.Pool}, orgID)
250 if err != nil {
251 h.d.Logger.WarnContext(r.Context(), "org billing: private collaboration usage", "org_id", orgID, "error", err)
252 return billingPrivateCollaborationBreakdown{
253 LimitLabel: "Unavailable",
254 Detail: "Private collaborator usage could not be calculated right now.",
255 }
256 }
257 if usage.Unlimited {
258 return billingPrivateCollaborationBreakdown{
259 Count: usage.Count,
260 LimitLabel: "Unlimited",
261 Detail: "Team billing allows unlimited effective private collaborators while the subscription is active or in grace.",
262 }
263 }
264 return billingPrivateCollaborationBreakdown{
265 Count: usage.Count,
266 LimitLabel: fmt.Sprintf("%d", usage.Limit),
267 Detail: "Free organizations can add up to 3 unique people with effective access to private org repositories. Public collaboration is not counted.",
268 }
269 }
270
271 func (h *Handlers) ensureStripeCustomer(r *http.Request, org orgsdb.Org, state orgbilling.State) (orgbilling.State, error) {
272 if state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "" {
273 return state, nil
274 }
275 customer, err := h.d.Stripe.CreateCustomer(r.Context(), stripebilling.CustomerInput{
276 OrgID: org.ID,
277 OrgSlug: org.Slug,
278 OrgName: strings.TrimSpace(org.DisplayName),
279 Email: strings.TrimSpace(org.BillingEmail),
280 })
281 if err != nil {
282 return orgbilling.State{}, err
283 }
284 return orgbilling.SetStripeCustomer(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, org.ID, customer.ID)
285 }
286
287 func (h *Handlers) billingReturnURL(orgSlug, overrideURL, fallbackPath string) string {
288 overrideURL = strings.TrimSpace(overrideURL)
289 if overrideURL != "" {
290 return strings.ReplaceAll(overrideURL, "{org}", url.PathEscape(orgSlug))
291 }
292 base, err := url.Parse(strings.TrimRight(h.d.BaseURL, "/") + "/")
293 if err != nil {
294 return ""
295 }
296 rel, err := url.Parse(strings.TrimLeft(fallbackPath, "/"))
297 if err != nil {
298 return ""
299 }
300 return base.ResolveReference(rel).String()
301 }
302
303 func orgBillingSettingsPath(slug string) string {
304 return "/organizations/" + slug + "/settings/billing"
305 }
306
307 func billingNotice(code string) string {
308 switch code {
309 case "checkout-success":
310 return "Checkout completed. Stripe will finish provisioning as webhook events arrive."
311 case "checkout-canceled":
312 return "Checkout canceled."
313 case "team-created":
314 return "Organization created. Continue with Team checkout to unlock paid features."
315 case "team-created-import-started":
316 return "Organization created and GitHub import started. Continue with Team checkout to unlock paid features."
317 case "team-checkout-failed":
318 return "Organization created, but checkout could not be started. Try Continue with Team again."
319 default:
320 return ""
321 }
322 }
323
324 func (h *Handlers) billingDebugView(r *http.Request, state orgbilling.State) billingDebugView {
325 debug := billingDebugView{
326 StripeCustomerID: pgTextString(state.StripeCustomerID),
327 StripeSubscriptionID: pgTextString(state.StripeSubscriptionID),
328 StripeSubscriptionItemID: pgTextString(state.StripeSubscriptionItemID),
329 LastWebhookEventID: strings.TrimSpace(state.LastWebhookEventID),
330 }
331 if debug.LastWebhookEventID == "" {
332 return debug
333 }
334 receipt, err := orgbilling.GetWebhookEventReceipt(r.Context(), orgbilling.Deps{Pool: h.d.Pool}, debug.LastWebhookEventID)
335 if err != nil {
336 if !errors.Is(err, pgx.ErrNoRows) {
337 h.d.Logger.WarnContext(r.Context(), "org billing: load latest webhook receipt",
338 "event_id", debug.LastWebhookEventID, "error", err)
339 }
340 return debug
341 }
342 debug.LastWebhookEventType = receipt.EventType
343 debug.LastWebhookReceivedAt = formatOptionalTime(receipt.ReceivedAt)
344 debug.LastWebhookProcessedAt = formatOptionalTime(receipt.ProcessedAt)
345 debug.LastWebhookAttempts = receipt.ProcessingAttempts
346 debug.LastWebhookError = strings.TrimSpace(receipt.ProcessError)
347 switch {
348 case receipt.ProcessedAt.Valid:
349 debug.LastWebhookStatus = "processed"
350 case debug.LastWebhookError != "":
351 debug.LastWebhookStatus = "failed"
352 default:
353 debug.LastWebhookStatus = "pending"
354 }
355 return debug
356 }
357
358 func billingSummary(state orgbilling.State, memberCount int) []billingSummaryItem {
359 summary := []billingSummaryItem{
360 {
361 Label: "Current plan",
362 Value: billingPlanLabel(state.Plan),
363 Detail: billingPlanDetail(state),
364 },
365 {
366 Label: "Subscription",
367 Value: billingStatusLabel(state.SubscriptionStatus),
368 Detail: billingStatusDetail(state),
369 },
370 {
371 Label: "Billable members",
372 Value: fmt.Sprintf("%d", memberCount),
373 Detail: billingSeatDetail(state),
374 },
375 {
376 Label: "Payment source",
377 Value: billingPaymentSourceLabel(state),
378 Detail: billingPaymentSourceDetail(state),
379 },
380 }
381 return summary
382 }
383
384 func billingPlanLabel(plan orgbilling.Plan) string {
385 switch plan {
386 case orgbilling.PlanTeam:
387 return "Team"
388 case orgbilling.PlanEnterprise:
389 return "Enterprise"
390 default:
391 return "Free"
392 }
393 }
394
395 func billingPlanDetail(state orgbilling.State) string {
396 if state.CurrentPeriodEnd.Valid {
397 label := "Current period ends"
398 if state.CancelAtPeriodEnd {
399 label = "Scheduled to cancel at period end"
400 }
401 return label + " " + state.CurrentPeriodEnd.Time.Format("Jan 2, 2006")
402 }
403 if state.SubscriptionStatus == orgbilling.SubscriptionStatusNone ||
404 state.SubscriptionStatus == orgbilling.SubscriptionStatusCanceled {
405 return "No active paid subscription."
406 }
407 return ""
408 }
409
410 func billingStatusLabel(status orgbilling.SubscriptionStatus) string {
411 switch status {
412 case orgbilling.SubscriptionStatusActive:
413 return "Active"
414 case orgbilling.SubscriptionStatusTrialing:
415 return "Trialing"
416 case orgbilling.SubscriptionStatusIncomplete:
417 return "Incomplete"
418 case orgbilling.SubscriptionStatusPastDue:
419 return "Past due"
420 case orgbilling.SubscriptionStatusCanceled:
421 return "Canceled"
422 case orgbilling.SubscriptionStatusUnpaid:
423 return "Unpaid"
424 case orgbilling.SubscriptionStatusPaused:
425 return "Paused"
426 default:
427 return "No subscription"
428 }
429 }
430
431 func billingStatusDetail(state orgbilling.State) string {
432 if state.GraceUntil.Valid {
433 return "Grace period until " + state.GraceUntil.Time.Format("Jan 2, 2006")
434 }
435 if state.CanceledAt.Valid {
436 return "Canceled " + state.CanceledAt.Time.Format("Jan 2, 2006")
437 }
438 if state.TrialEnd.Valid {
439 return "Trial ends " + state.TrialEnd.Time.Format("Jan 2, 2006")
440 }
441 return ""
442 }
443
444 func billingSeatDetail(state orgbilling.State) string {
445 if state.SeatSnapshotAt.Valid {
446 return fmt.Sprintf("Latest billed seat snapshot: %d captured %s", state.BillableSeats, state.SeatSnapshotAt.Time.Format("Jan 2, 2006"))
447 }
448 if state.BillableSeats > 0 {
449 return fmt.Sprintf("Latest billed seat snapshot: %d", state.BillableSeats)
450 }
451 return "Seat sync has not recorded a snapshot yet."
452 }
453
454 func billingPaymentSourceLabel(state orgbilling.State) string {
455 if state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "" {
456 return "Stripe customer connected"
457 }
458 return "Not connected"
459 }
460
461 func billingPaymentSourceDetail(state orgbilling.State) string {
462 if state.StripeCustomerID.Valid && strings.TrimSpace(state.StripeCustomerID.String) != "" {
463 return "Payment method and invoices are managed in Stripe Billing Portal."
464 }
465 return "Checkout creates a customer record the first time this organization upgrades."
466 }
467
468 func billingAlertForState(state orgbilling.State, orgSlug string) billingAlert {
469 path := orgBillingSettingsPath(orgSlug)
470 switch state.SubscriptionStatus {
471 case orgbilling.SubscriptionStatusPastDue:
472 if state.GraceUntil.Valid && time.Now().UTC().Before(state.GraceUntil.Time) {
473 return billingAlert{
474 Class: "shithub-flash-notice",
475 Message: "Payment failed. Team features remain available during the billing grace period, which ends " + state.GraceUntil.Time.Format("Jan 2, 2006") + ".",
476 ActionText: "Manage billing",
477 ActionHref: path,
478 }
479 }
480 return billingAlert{
481 Class: "shithub-flash-error",
482 Message: "Payment is past due. Team-only features are read-only until billing is brought back into good standing.",
483 ActionText: "Manage billing",
484 ActionHref: path,
485 }
486 case orgbilling.SubscriptionStatusCanceled:
487 return billingAlert{
488 Class: "shithub-flash-notice",
489 Message: "This organization is on Free after cancellation. Existing paid configuration is preserved, but Team-only features are read-only until reactivated.",
490 ActionText: "Upgrade to Team",
491 ActionHref: path + "#manage-plan",
492 }
493 case orgbilling.SubscriptionStatusIncomplete, orgbilling.SubscriptionStatusUnpaid, orgbilling.SubscriptionStatusPaused:
494 return billingAlert{
495 Class: "shithub-flash-error",
496 Message: "This subscription needs billing action before Team features are available.",
497 ActionText: "Manage billing",
498 ActionHref: path,
499 }
500 default:
501 if state.CancelAtPeriodEnd && state.CurrentPeriodEnd.Valid {
502 return billingAlert{
503 Class: "shithub-flash-notice",
504 Message: "Team is scheduled to cancel at the end of the current billing period on " + state.CurrentPeriodEnd.Time.Format("Jan 2, 2006") + ".",
505 ActionText: "Manage billing",
506 ActionHref: path,
507 }
508 }
509 return billingAlert{}
510 }
511 }
512
513 func billingInvoiceViews(invoices []billingdb.BillingInvoice) []billingInvoiceView {
514 items := make([]billingInvoiceView, 0, len(invoices))
515 for _, inv := range invoices {
516 number := strings.TrimSpace(inv.Number)
517 if number == "" {
518 number = inv.StripeInvoiceID
519 }
520 items = append(items, billingInvoiceView{
521 Number: number,
522 StatusLabel: billingInvoiceStatusLabel(inv.Status),
523 StatusClass: strings.ReplaceAll(strings.ToLower(string(inv.Status)), "_", "-"),
524 AmountLabel: formatCurrencyAmount(inv.Currency, inv.AmountDueCents),
525 PeriodLabel: billingPeriodLabel(inv),
526 DueLabel: billingDueLabel(inv),
527 HostedInvoiceURL: strings.TrimSpace(inv.HostedInvoiceUrl),
528 InvoicePDFURL: strings.TrimSpace(inv.InvoicePdfUrl),
529 })
530 }
531 return items
532 }
533
534 func billingInvoiceStatusLabel(status orgbilling.InvoiceStatus) string {
535 switch status {
536 case orgbilling.InvoiceStatusOpen:
537 return "Open"
538 case orgbilling.InvoiceStatusPaid:
539 return "Paid"
540 case orgbilling.InvoiceStatusVoid:
541 return "Void"
542 case orgbilling.InvoiceStatusUncollectible:
543 return "Uncollectible"
544 default:
545 return "Draft"
546 }
547 }
548
549 func billingPeriodLabel(inv billingdb.BillingInvoice) string {
550 if inv.PeriodStart.Valid && inv.PeriodEnd.Valid {
551 return inv.PeriodStart.Time.Format("Jan 2, 2006") + " - " + inv.PeriodEnd.Time.Format("Jan 2, 2006")
552 }
553 return "—"
554 }
555
556 func billingDueLabel(inv billingdb.BillingInvoice) string {
557 switch {
558 case inv.PaidAt.Valid:
559 return "Paid " + inv.PaidAt.Time.Format("Jan 2, 2006")
560 case inv.VoidedAt.Valid:
561 return "Voided " + inv.VoidedAt.Time.Format("Jan 2, 2006")
562 case inv.DueAt.Valid:
563 return inv.DueAt.Time.Format("Jan 2, 2006")
564 default:
565 return "—"
566 }
567 }
568
569 func formatGracePeriod(d time.Duration) string {
570 if d <= 0 {
571 return "No grace period"
572 }
573 if d%(24*time.Hour) == 0 {
574 days := int(d / (24 * time.Hour))
575 if days == 1 {
576 return "1 day"
577 }
578 return fmt.Sprintf("%d days", days)
579 }
580 return d.String()
581 }
582
583 func pgTextString(v pgtype.Text) string {
584 if !v.Valid {
585 return ""
586 }
587 return strings.TrimSpace(v.String)
588 }
589
590 func formatOptionalTime(v pgtype.Timestamptz) string {
591 if v.Valid && !v.Time.IsZero() {
592 return v.Time.UTC().Format("Jan 2, 2006 15:04 UTC")
593 }
594 return ""
595 }
596
597 func formatCurrencyAmount(currency string, cents int64) string {
598 currency = strings.ToUpper(strings.TrimSpace(currency))
599 sign := ""
600 if cents < 0 {
601 sign = "-"
602 cents = -cents
603 }
604 major := cents / 100
605 minor := cents % 100
606 if currency == "" {
607 currency = "USD"
608 }
609 return fmt.Sprintf("%s$%d.%02d %s", sign, major, minor, currency)
610 }
611