Coverage for polar/order/endpoints.py: 86%

61 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 12:42 +0000

1from collections.abc import AsyncGenerator 

2 

3from fastapi import Depends, Query, Response 

4from fastapi.responses import StreamingResponse 

5from pydantic import UUID4 

6 

7from polar.customer.schemas.customer import CustomerID 

8from polar.exceptions import ResourceNotFound 

9from polar.kit.csv import IterableCSVWriter 

10from polar.kit.metadata import MetadataQuery, get_metadata_query_openapi_schema 

11from polar.kit.pagination import ListResource, PaginationParams, PaginationParamsQuery 

12from polar.kit.schemas import MultipleQueryFilter 

13from polar.models import Order 

14from polar.models.product import ProductBillingType 

15from polar.openapi import APITag 

16from polar.organization.schemas import OrganizationID 

17from polar.postgres import ( 

18 AsyncReadSession, 

19 AsyncSession, 

20 get_db_read_session, 

21 get_db_session, 

22) 

23from polar.product.schemas import ProductID 

24from polar.routing import APIRouter 

25 

26from . import auth, sorting 

27from .schemas import Order as OrderSchema 

28from .schemas import OrderID, OrderInvoice, OrderNotFound, OrderUpdate 

29from .service import MissingInvoiceBillingDetails, NotPaidOrder 

30from .service import order as order_service 

31 

32router = APIRouter(prefix="/orders", tags=["orders", APITag.public, APITag.mcp]) 

33 

34 

35@router.get( 

36 "/", 

37 summary="List Orders", 

38 response_model=ListResource[OrderSchema], 

39 openapi_extra={"parameters": [get_metadata_query_openapi_schema()]}, 

40) 

41async def list( 

42 auth_subject: auth.OrdersRead, 

43 pagination: PaginationParamsQuery, 

44 sorting: sorting.ListSorting, 

45 metadata: MetadataQuery, 

46 organization_id: MultipleQueryFilter[OrganizationID] | None = Query( 

47 None, title="OrganizationID Filter", description="Filter by organization ID." 

48 ), 

49 product_id: MultipleQueryFilter[ProductID] | None = Query( 

50 None, title="ProductID Filter", description="Filter by product ID." 

51 ), 

52 product_billing_type: MultipleQueryFilter[ProductBillingType] | None = Query( 

53 None, 

54 title="ProductBillingType Filter", 

55 description=( 

56 "Filter by product billing type. " 

57 "`recurring` will filter data corresponding " 

58 "to subscriptions creations or renewals. " 

59 "`one_time` will filter data corresponding to one-time purchases." 

60 ), 

61 ), 

62 discount_id: MultipleQueryFilter[UUID4] | None = Query( 

63 None, title="DiscountID Filter", description="Filter by discount ID." 

64 ), 

65 customer_id: MultipleQueryFilter[CustomerID] | None = Query( 

66 None, title="CustomerID Filter", description="Filter by customer ID." 

67 ), 

68 checkout_id: MultipleQueryFilter[UUID4] | None = Query( 

69 None, title="CheckoutID Filter", description="Filter by checkout ID." 

70 ), 

71 session: AsyncReadSession = Depends(get_db_read_session), 

72) -> ListResource[OrderSchema]: 

73 """List orders.""" 

74 results, count = await order_service.list( 

75 session, 

76 auth_subject, 

77 organization_id=organization_id, 

78 product_id=product_id, 

79 product_billing_type=product_billing_type, 

80 discount_id=discount_id, 

81 customer_id=customer_id, 

82 checkout_id=checkout_id, 

83 metadata=metadata, 

84 pagination=pagination, 

85 sorting=sorting, 

86 ) 

87 

88 return ListResource.from_paginated_results( 

89 [OrderSchema.model_validate(result) for result in results], 

90 count, 

91 pagination, 

92 ) 

93 

94 

95@router.get("/export", summary="Export Subscriptions") 

96async def export( 

97 auth_subject: auth.OrdersRead, 

98 organization_id: MultipleQueryFilter[OrganizationID] | None = Query( 

99 None, title="OrganizationID Filter", description="Filter by organization ID." 

100 ), 

101 product_id: MultipleQueryFilter[ProductID] | None = Query( 

102 None, title="ProductID Filter", description="Filter by product ID." 

103 ), 

104 session: AsyncReadSession = Depends(get_db_read_session), 

105) -> Response: 

106 """Export orders as a CSV file.""" 

107 

108 async def create_csv() -> AsyncGenerator[str, None]: 

109 csv_writer = IterableCSVWriter(dialect="excel") 

110 # CSV header 

111 yield csv_writer.getrow( 

112 ( 

113 "Email", 

114 "Created At", 

115 "Product", 

116 "Amount", 

117 "Currency", 

118 "Status", 

119 "Invoice number", 

120 ) 

121 ) 

122 

123 (results, _) = await order_service.list( 

124 session, 

125 auth_subject, 

126 organization_id=organization_id, 

127 product_id=product_id, 

128 pagination=PaginationParams(limit=1000000, page=1), 

129 ) 

130 

131 for order in results: 131 ↛ 132line 131 didn't jump to line 132 because the loop on line 131 never started

132 yield csv_writer.getrow( 

133 ( 

134 order.customer.email, 

135 order.created_at.isoformat(), 

136 order.description, 

137 order.net_amount / 100, 

138 order.currency, 

139 order.status, 

140 order.invoice_number, 

141 ) 

142 ) 

143 

144 filename = "polar-orders.csv" 

145 return StreamingResponse( 

146 create_csv(), 

147 media_type="text/csv", 

148 headers={"Content-Disposition": f"attachment; filename={filename}"}, 

149 ) 

150 

151 

152@router.get( 

153 "/{id}", 

154 summary="Get Order", 

155 response_model=OrderSchema, 

156 responses={404: OrderNotFound}, 

157) 

158async def get( 

159 id: OrderID, 

160 auth_subject: auth.OrdersRead, 

161 session: AsyncReadSession = Depends(get_db_read_session), 

162) -> Order: 

163 """Get an order by ID.""" 

164 order = await order_service.get(session, auth_subject, id) 

165 

166 if order is None: 166 ↛ 169line 166 didn't jump to line 169 because the condition on line 166 was always true

167 raise ResourceNotFound() 

168 

169 return order 

170 

171 

172@router.patch( 

173 "/{id}", 

174 summary="Update Order", 

175 response_model=OrderSchema, 

176 responses={404: OrderNotFound}, 

177) 

178async def update( 

179 id: OrderID, 

180 order_update: OrderUpdate, 

181 auth_subject: auth.OrdersWrite, 

182 session: AsyncSession = Depends(get_db_session), 

183) -> Order: 

184 """Update an order.""" 

185 order = await order_service.get(session, auth_subject, id) 

186 

187 if order is None: 187 ↛ 190line 187 didn't jump to line 190 because the condition on line 187 was always true

188 raise ResourceNotFound() 

189 

190 return await order_service.update(session, order, order_update) 

191 

192 

193@router.post( 

194 "/{id}/invoice", 

195 status_code=202, 

196 summary="Generate Order Invoice", 

197 responses={ 

198 422: { 

199 "description": "Order is not paid or is missing billing name or address.", 

200 "model": MissingInvoiceBillingDetails.schema() | NotPaidOrder.schema(), 

201 }, 

202 }, 

203) 

204async def generate_invoice( 

205 id: OrderID, 

206 auth_subject: auth.OrdersRead, 

207 session: AsyncSession = Depends(get_db_session), 

208) -> None: 

209 """Trigger generation of an order's invoice.""" 

210 order = await order_service.get(session, auth_subject, id) 

211 

212 if order is None: 212 ↛ 215line 212 didn't jump to line 215 because the condition on line 212 was always true

213 raise ResourceNotFound() 

214 

215 await order_service.trigger_invoice_generation(session, order) 

216 

217 

218@router.get( 

219 "/{id}/invoice", 

220 summary="Get Order Invoice", 

221 response_model=OrderInvoice, 

222 responses={404: OrderNotFound}, 

223) 

224async def invoice( 

225 id: OrderID, 

226 auth_subject: auth.OrdersRead, 

227 session: AsyncReadSession = Depends(get_db_read_session), 

228) -> OrderInvoice: 

229 """Get an order's invoice data.""" 

230 order = await order_service.get(session, auth_subject, id) 

231 

232 if order is None: 232 ↛ 235line 232 didn't jump to line 235 because the condition on line 232 was always true

233 raise ResourceNotFound() 

234 

235 return await order_service.get_order_invoice(order)