tenseleyflow/shithub / 5949bec

Browse files

billing: MarkInvoiceRefunded sqlc + InvoiceStatusRefunded constant

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
5949bec3d283475d8038989891678e8156671f46
Parents
31abd7f
Tree
d7dc595

5 changed files

StatusFile+-
M internal/billing/billing.go 20 1
M internal/billing/queries/billing.sql 17 0
M internal/billing/sqlc/billing.sql.go 58 4
M internal/billing/sqlc/models.go 4 0
M internal/billing/sqlc/querier.go 9 0
internal/billing/billing.gomodified
@@ -55,6 +55,7 @@ const (
5555
 	InvoiceStatusPaid          = billingdb.BillingInvoiceStatusPaid
5656
 	InvoiceStatusVoid          = billingdb.BillingInvoiceStatusVoid
5757
 	InvoiceStatusUncollectible = billingdb.BillingInvoiceStatusUncollectible
58
+	InvoiceStatusRefunded      = billingdb.BillingInvoiceStatusRefunded
5859
 )
5960
 
6061
 var (
@@ -317,6 +318,23 @@ func SetWebhookEventSubjectForPrincipal(ctx context.Context, deps Deps, provider
317318
 	})
318319
 }
319320
 
321
+// MarkInvoiceRefunded flips a billing_invoices row to status='refunded'
322
+// and stamps refunded_at. PRO08 D2: surface a Stripe-side refund in
323
+// shithub's billing settings UI. The Stripe invoice itself stays
324
+// status='paid' after a refund; shithub maintains its own UI surface.
325
+// Returns pgx.ErrNoRows when the invoice id isn't on file (Stripe
326
+// refunded an invoice we never recorded — operator should reconcile).
327
+func MarkInvoiceRefunded(ctx context.Context, deps Deps, stripeInvoiceID string) (billingdb.BillingInvoice, error) {
328
+	if err := validateDeps(deps); err != nil {
329
+		return billingdb.BillingInvoice{}, err
330
+	}
331
+	stripeInvoiceID = strings.TrimSpace(stripeInvoiceID)
332
+	if stripeInvoiceID == "" {
333
+		return billingdb.BillingInvoice{}, ErrStripeInvoiceID
334
+	}
335
+	return billingdb.New().MarkInvoiceRefunded(ctx, deps.Pool, stripeInvoiceID)
336
+}
337
+
320338
 // IsBillingEventStaleForPrincipal reports whether an incoming Stripe
321339
 // event's timestamp is older than the last event we've already applied
322340
 // for this principal. PRO08 D4: the handler refuses stale events so
@@ -630,7 +648,8 @@ func validInvoiceStatus(status InvoiceStatus) bool {
630648
 		InvoiceStatusOpen,
631649
 		InvoiceStatusPaid,
632650
 		InvoiceStatusVoid,
633
-		InvoiceStatusUncollectible:
651
+		InvoiceStatusUncollectible,
652
+		InvoiceStatusRefunded:
634653
 		return true
635654
 	default:
636655
 		return false
internal/billing/queries/billing.sqlmodified
@@ -509,6 +509,23 @@ UPDATE user_billing_states
509509
    SET last_event_at = GREATEST(COALESCE(last_event_at, sqlc.arg(event_at)::timestamptz), sqlc.arg(event_at)::timestamptz)
510510
  WHERE user_id = sqlc.arg(user_id)::bigint;
511511
 
512
+-- name: MarkInvoiceRefunded :one
513
+-- PRO08 D2: surface a Stripe-side refund in shithub. Stripe leaves
514
+-- the invoice.status='paid' after a refund and fires a charge.refunded
515
+-- event; this helper flips the shithub-side row to 'refunded' so the
516
+-- billing settings UI shows the refunded state.
517
+--
518
+-- A NULL refunded_at means "no refund seen"; the value is set on the
519
+-- first call and preserved on subsequent calls (refund partial → full
520
+-- doesn't move the wall-clock timestamp).
521
+UPDATE billing_invoices
522
+   SET status = 'refunded',
523
+       refunded_at = COALESCE(refunded_at, now()),
524
+       updated_at = now()
525
+ WHERE provider = 'stripe'
526
+   AND stripe_invoice_id = sqlc.arg(stripe_invoice_id)::text
527
+RETURNING *;
528
+
512529
 -- name: TryAcquireWebhookEventLock :one
513530
 -- PRO08 A3: transaction-scoped advisory lock keyed on the hash of
514531
 -- the provider_event_id. Two concurrent webhook deliveries for the
internal/billing/sqlc/billing.sql.gomodified
@@ -1019,7 +1019,7 @@ func (q *Queries) ListFailedWebhookEvents(ctx context.Context, db DBTX, limit in
10191019
 }
10201020
 
10211021
 const listInvoicesForOrg = `-- name: ListInvoicesForOrg :many
1022
-SELECT id, org_id, provider, stripe_invoice_id, stripe_customer_id, stripe_subscription_id, status, number, currency, amount_due_cents, amount_paid_cents, amount_remaining_cents, hosted_invoice_url, invoice_pdf_url, period_start, period_end, due_at, paid_at, voided_at, created_at, updated_at, subject_kind, subject_id FROM billing_invoices
1022
+SELECT id, org_id, provider, stripe_invoice_id, stripe_customer_id, stripe_subscription_id, status, number, currency, amount_due_cents, amount_paid_cents, amount_remaining_cents, hosted_invoice_url, invoice_pdf_url, period_start, period_end, due_at, paid_at, voided_at, created_at, updated_at, subject_kind, subject_id, refunded_at FROM billing_invoices
10231023
 WHERE subject_kind = 'org' AND subject_id = $1
10241024
 ORDER BY created_at DESC, id DESC
10251025
 LIMIT $2
@@ -1067,6 +1067,7 @@ func (q *Queries) ListInvoicesForOrg(ctx context.Context, db DBTX, arg ListInvoi
10671067
 			&i.UpdatedAt,
10681068
 			&i.SubjectKind,
10691069
 			&i.SubjectID,
1070
+			&i.RefundedAt,
10701071
 		); err != nil {
10711072
 			return nil, err
10721073
 		}
@@ -1079,7 +1080,7 @@ func (q *Queries) ListInvoicesForOrg(ctx context.Context, db DBTX, arg ListInvoi
10791080
 }
10801081
 
10811082
 const listInvoicesForSubject = `-- name: ListInvoicesForSubject :many
1082
-SELECT id, org_id, provider, stripe_invoice_id, stripe_customer_id, stripe_subscription_id, status, number, currency, amount_due_cents, amount_paid_cents, amount_remaining_cents, hosted_invoice_url, invoice_pdf_url, period_start, period_end, due_at, paid_at, voided_at, created_at, updated_at, subject_kind, subject_id FROM billing_invoices
1083
+SELECT id, org_id, provider, stripe_invoice_id, stripe_customer_id, stripe_subscription_id, status, number, currency, amount_due_cents, amount_paid_cents, amount_remaining_cents, hosted_invoice_url, invoice_pdf_url, period_start, period_end, due_at, paid_at, voided_at, created_at, updated_at, subject_kind, subject_id, refunded_at FROM billing_invoices
10831084
 WHERE subject_kind = $1::billing_subject_kind
10841085
   AND subject_id = $2::bigint
10851086
 ORDER BY created_at DESC, id DESC
@@ -1129,6 +1130,7 @@ func (q *Queries) ListInvoicesForSubject(ctx context.Context, db DBTX, arg ListI
11291130
 			&i.UpdatedAt,
11301131
 			&i.SubjectKind,
11311132
 			&i.SubjectID,
1133
+			&i.RefundedAt,
11321134
 		); err != nil {
11331135
 			return nil, err
11341136
 		}
@@ -1265,6 +1267,56 @@ func (q *Queries) MarkCanceled(ctx context.Context, db DBTX, arg MarkCanceledPar
12651267
 	return i, err
12661268
 }
12671269
 
1270
+const markInvoiceRefunded = `-- name: MarkInvoiceRefunded :one
1271
+UPDATE billing_invoices
1272
+   SET status = 'refunded',
1273
+       refunded_at = COALESCE(refunded_at, now()),
1274
+       updated_at = now()
1275
+ WHERE provider = 'stripe'
1276
+   AND stripe_invoice_id = $1::text
1277
+RETURNING id, org_id, provider, stripe_invoice_id, stripe_customer_id, stripe_subscription_id, status, number, currency, amount_due_cents, amount_paid_cents, amount_remaining_cents, hosted_invoice_url, invoice_pdf_url, period_start, period_end, due_at, paid_at, voided_at, created_at, updated_at, subject_kind, subject_id, refunded_at
1278
+`
1279
+
1280
+// PRO08 D2: surface a Stripe-side refund in shithub. Stripe leaves
1281
+// the invoice.status='paid' after a refund and fires a charge.refunded
1282
+// event; this helper flips the shithub-side row to 'refunded' so the
1283
+// billing settings UI shows the refunded state.
1284
+//
1285
+// A NULL refunded_at means "no refund seen"; the value is set on the
1286
+// first call and preserved on subsequent calls (refund partial → full
1287
+// doesn't move the wall-clock timestamp).
1288
+func (q *Queries) MarkInvoiceRefunded(ctx context.Context, db DBTX, stripeInvoiceID string) (BillingInvoice, error) {
1289
+	row := db.QueryRow(ctx, markInvoiceRefunded, stripeInvoiceID)
1290
+	var i BillingInvoice
1291
+	err := row.Scan(
1292
+		&i.ID,
1293
+		&i.OrgID,
1294
+		&i.Provider,
1295
+		&i.StripeInvoiceID,
1296
+		&i.StripeCustomerID,
1297
+		&i.StripeSubscriptionID,
1298
+		&i.Status,
1299
+		&i.Number,
1300
+		&i.Currency,
1301
+		&i.AmountDueCents,
1302
+		&i.AmountPaidCents,
1303
+		&i.AmountRemainingCents,
1304
+		&i.HostedInvoiceUrl,
1305
+		&i.InvoicePdfUrl,
1306
+		&i.PeriodStart,
1307
+		&i.PeriodEnd,
1308
+		&i.DueAt,
1309
+		&i.PaidAt,
1310
+		&i.VoidedAt,
1311
+		&i.CreatedAt,
1312
+		&i.UpdatedAt,
1313
+		&i.SubjectKind,
1314
+		&i.SubjectID,
1315
+		&i.RefundedAt,
1316
+	)
1317
+	return i, err
1318
+}
1319
+
12681320
 const markPastDue = `-- name: MarkPastDue :one
12691321
 UPDATE org_billing_states
12701322
    SET subscription_status = 'past_due',
@@ -1920,7 +1972,7 @@ ON CONFLICT (provider, stripe_invoice_id) DO UPDATE
19201972
        paid_at = EXCLUDED.paid_at,
19211973
        voided_at = EXCLUDED.voided_at,
19221974
        updated_at = now()
1923
-RETURNING id, org_id, provider, stripe_invoice_id, stripe_customer_id, stripe_subscription_id, status, number, currency, amount_due_cents, amount_paid_cents, amount_remaining_cents, hosted_invoice_url, invoice_pdf_url, period_start, period_end, due_at, paid_at, voided_at, created_at, updated_at, subject_kind, subject_id
1975
+RETURNING id, org_id, provider, stripe_invoice_id, stripe_customer_id, stripe_subscription_id, status, number, currency, amount_due_cents, amount_paid_cents, amount_remaining_cents, hosted_invoice_url, invoice_pdf_url, period_start, period_end, due_at, paid_at, voided_at, created_at, updated_at, subject_kind, subject_id, refunded_at
19241976
 `
19251977
 
19261978
 type UpsertInvoiceParams struct {
@@ -1994,6 +2046,7 @@ func (q *Queries) UpsertInvoice(ctx context.Context, db DBTX, arg UpsertInvoiceP
19942046
 		&i.UpdatedAt,
19952047
 		&i.SubjectKind,
19962048
 		&i.SubjectID,
2049
+		&i.RefundedAt,
19972050
 	)
19982051
 	return i, err
19992052
 }
@@ -2060,7 +2113,7 @@ ON CONFLICT (provider, stripe_invoice_id) DO UPDATE
20602113
        paid_at = EXCLUDED.paid_at,
20612114
        voided_at = EXCLUDED.voided_at,
20622115
        updated_at = now()
2063
-RETURNING id, org_id, provider, stripe_invoice_id, stripe_customer_id, stripe_subscription_id, status, number, currency, amount_due_cents, amount_paid_cents, amount_remaining_cents, hosted_invoice_url, invoice_pdf_url, period_start, period_end, due_at, paid_at, voided_at, created_at, updated_at, subject_kind, subject_id
2116
+RETURNING id, org_id, provider, stripe_invoice_id, stripe_customer_id, stripe_subscription_id, status, number, currency, amount_due_cents, amount_paid_cents, amount_remaining_cents, hosted_invoice_url, invoice_pdf_url, period_start, period_end, due_at, paid_at, voided_at, created_at, updated_at, subject_kind, subject_id, refunded_at
20642117
 `
20652118
 
20662119
 type UpsertInvoiceForSubjectParams struct {
@@ -2136,6 +2189,7 @@ func (q *Queries) UpsertInvoiceForSubject(ctx context.Context, db DBTX, arg Upse
21362189
 		&i.UpdatedAt,
21372190
 		&i.SubjectKind,
21382191
 		&i.SubjectID,
2192
+		&i.RefundedAt,
21392193
 	)
21402194
 	return i, err
21412195
 }
internal/billing/sqlc/models.gomodified
@@ -63,6 +63,7 @@ const (
6363
 	BillingInvoiceStatusPaid          BillingInvoiceStatus = "paid"
6464
 	BillingInvoiceStatusVoid          BillingInvoiceStatus = "void"
6565
 	BillingInvoiceStatusUncollectible BillingInvoiceStatus = "uncollectible"
66
+	BillingInvoiceStatusRefunded      BillingInvoiceStatus = "refunded"
6667
 )
6768
 
6869
 func (e *BillingInvoiceStatus) Scan(src interface{}) error {
@@ -1969,6 +1970,7 @@ type BillingInvoice struct {
19691970
 	UpdatedAt            pgtype.Timestamptz
19701971
 	SubjectKind          BillingSubjectKind
19711972
 	SubjectID            int64
1973
+	RefundedAt           pgtype.Timestamptz
19721974
 }
19731975
 
19741976
 type BillingSeatSnapshot struct {
@@ -2318,6 +2320,7 @@ type OrgBillingState struct {
23182320
 	LastWebhookEventID       string
23192321
 	CreatedAt                pgtype.Timestamptz
23202322
 	UpdatedAt                pgtype.Timestamptz
2323
+	LastEventAt              pgtype.Timestamptz
23212324
 }
23222325
 
23232326
 type OrgGithubImport struct {
@@ -2738,6 +2741,7 @@ type UserBillingState struct {
27382741
 	LastWebhookEventID       string
27392742
 	CreatedAt                pgtype.Timestamptz
27402743
 	UpdatedAt                pgtype.Timestamptz
2744
+	LastEventAt              pgtype.Timestamptz
27412745
 }
27422746
 
27432747
 type UserEmail struct {
internal/billing/sqlc/querier.gomodified
@@ -61,6 +61,15 @@ type Querier interface {
6161
 	ListInvoicesForSubject(ctx context.Context, db DBTX, arg ListInvoicesForSubjectParams) ([]BillingInvoice, error)
6262
 	ListSeatSnapshotsForOrg(ctx context.Context, db DBTX, arg ListSeatSnapshotsForOrgParams) ([]BillingSeatSnapshot, error)
6363
 	MarkCanceled(ctx context.Context, db DBTX, arg MarkCanceledParams) (MarkCanceledRow, error)
64
+	// PRO08 D2: surface a Stripe-side refund in shithub. Stripe leaves
65
+	// the invoice.status='paid' after a refund and fires a charge.refunded
66
+	// event; this helper flips the shithub-side row to 'refunded' so the
67
+	// billing settings UI shows the refunded state.
68
+	//
69
+	// A NULL refunded_at means "no refund seen"; the value is set on the
70
+	// first call and preserved on subsequent calls (refund partial → full
71
+	// doesn't move the wall-clock timestamp).
72
+	MarkInvoiceRefunded(ctx context.Context, db DBTX, stripeInvoiceID string) (BillingInvoice, error)
6473
 	MarkPastDue(ctx context.Context, db DBTX, arg MarkPastDueParams) (OrgBillingState, error)
6574
 	MarkPaymentSucceeded(ctx context.Context, db DBTX, arg MarkPaymentSucceededParams) (MarkPaymentSucceededRow, error)
6675
 	MarkUserCanceled(ctx context.Context, db DBTX, arg MarkUserCanceledParams) (MarkUserCanceledRow, error)