@@ -857,6 +857,71 @@ func (q *Queries) GetWebhookEventReceipt(ctx context.Context, db DBTX, providerE |
| 857 | return i, err | 857 | return i, err |
| 858 | } | 858 | } |
| 859 | | 859 | |
| | 860 | +const listFailedWebhookEvents = `-- name: ListFailedWebhookEvents :many |
| | 861 | +SELECT id, provider, provider_event_id, event_type, api_version, |
| | 862 | + received_at, processed_at, processing_attempts, process_error, |
| | 863 | + subject_kind, subject_id |
| | 864 | + FROM billing_webhook_events |
| | 865 | + WHERE provider = 'stripe' |
| | 866 | + AND ( |
| | 867 | + process_error <> '' |
| | 868 | + OR (processed_at IS NULL AND processing_attempts > 0) |
| | 869 | + ) |
| | 870 | + ORDER BY received_at DESC |
| | 871 | + LIMIT $1 |
| | 872 | +` |
| | 873 | + |
| | 874 | +type ListFailedWebhookEventsRow struct { |
| | 875 | + ID int64 |
| | 876 | + Provider BillingProvider |
| | 877 | + ProviderEventID string |
| | 878 | + EventType string |
| | 879 | + ApiVersion string |
| | 880 | + ReceivedAt pgtype.Timestamptz |
| | 881 | + ProcessedAt pgtype.Timestamptz |
| | 882 | + ProcessingAttempts int32 |
| | 883 | + ProcessError string |
| | 884 | + SubjectKind NullBillingSubjectKind |
| | 885 | + SubjectID pgtype.Int8 |
| | 886 | +} |
| | 887 | + |
| | 888 | +// Operator query for "events we received but failed to process." |
| | 889 | +// A row is "failed" when it has a non-empty process_error OR when |
| | 890 | +// it has never been processed (processed_at NULL) and has at least |
| | 891 | +// one processing attempt. Rows that are merely new and untouched |
| | 892 | +// (attempts=0, processed_at NULL, no error) are excluded. |
| | 893 | +func (q *Queries) ListFailedWebhookEvents(ctx context.Context, db DBTX, limit int32) ([]ListFailedWebhookEventsRow, error) { |
| | 894 | + rows, err := db.Query(ctx, listFailedWebhookEvents, limit) |
| | 895 | + if err != nil { |
| | 896 | + return nil, err |
| | 897 | + } |
| | 898 | + defer rows.Close() |
| | 899 | + items := []ListFailedWebhookEventsRow{} |
| | 900 | + for rows.Next() { |
| | 901 | + var i ListFailedWebhookEventsRow |
| | 902 | + if err := rows.Scan( |
| | 903 | + &i.ID, |
| | 904 | + &i.Provider, |
| | 905 | + &i.ProviderEventID, |
| | 906 | + &i.EventType, |
| | 907 | + &i.ApiVersion, |
| | 908 | + &i.ReceivedAt, |
| | 909 | + &i.ProcessedAt, |
| | 910 | + &i.ProcessingAttempts, |
| | 911 | + &i.ProcessError, |
| | 912 | + &i.SubjectKind, |
| | 913 | + &i.SubjectID, |
| | 914 | + ); err != nil { |
| | 915 | + return nil, err |
| | 916 | + } |
| | 917 | + items = append(items, i) |
| | 918 | + } |
| | 919 | + if err := rows.Err(); err != nil { |
| | 920 | + return nil, err |
| | 921 | + } |
| | 922 | + return items, nil |
| | 923 | +} |
| | 924 | + |
| 860 | const listInvoicesForOrg = `-- name: ListInvoicesForOrg :many | 925 | const listInvoicesForOrg = `-- name: ListInvoicesForOrg :many |
| 861 | 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 | 926 | 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 |
| 862 | WHERE subject_kind = 'org' AND subject_id = $1 | 927 | WHERE subject_kind = 'org' AND subject_id = $1 |
@@ -1602,6 +1667,30 @@ func (q *Queries) SetUserStripeCustomer(ctx context.Context, db DBTX, arg SetUse |
| 1602 | return i, err | 1667 | return i, err |
| 1603 | } | 1668 | } |
| 1604 | | 1669 | |
| | 1670 | +const setWebhookEventSubject = `-- name: SetWebhookEventSubject :exec |
| | 1671 | +UPDATE billing_webhook_events |
| | 1672 | + SET subject_kind = $1::billing_subject_kind, |
| | 1673 | + subject_id = $2::bigint |
| | 1674 | + WHERE provider = 'stripe' |
| | 1675 | + AND provider_event_id = $3::text |
| | 1676 | +` |
| | 1677 | + |
| | 1678 | +type SetWebhookEventSubjectParams struct { |
| | 1679 | + SubjectKind BillingSubjectKind |
| | 1680 | + SubjectID int64 |
| | 1681 | + ProviderEventID string |
| | 1682 | +} |
| | 1683 | + |
| | 1684 | +// Records the resolved subject on the receipt row after a successful |
| | 1685 | +// subject-resolution step. Called from the apply path before guard + |
| | 1686 | +// state mutation so the receipt carries the audit trail even if the |
| | 1687 | +// subsequent apply fails. Migration 0075's CHECK constraint enforces |
| | 1688 | +// both-or-neither; callers must pass a non-zero subject. |
| | 1689 | +func (q *Queries) SetWebhookEventSubject(ctx context.Context, db DBTX, arg SetWebhookEventSubjectParams) error { |
| | 1690 | + _, err := db.Exec(ctx, setWebhookEventSubject, arg.SubjectKind, arg.SubjectID, arg.ProviderEventID) |
| | 1691 | + return err |
| | 1692 | +} |
| | 1693 | + |
| 1605 | const upsertInvoice = `-- name: UpsertInvoice :one | 1694 | const upsertInvoice = `-- name: UpsertInvoice :one |
| 1606 | | 1695 | |
| 1607 | INSERT INTO billing_invoices ( | 1696 | INSERT INTO billing_invoices ( |